config.go

  1package config
  2
  3import (
  4	"encoding/json"
  5	"fmt"
  6	"os"
  7	"path/filepath"
  8	"strings"
  9
 10	"github.com/google/uuid"
 11	"github.com/zalando/go-keyring"
 12)
 13
 14const keyringServiceName = "matcha-email-client"
 15
 16// Date format presets use human-readable tokens. Supported tokens:
 17//
 18//	YYYY (4-digit year), YY (2-digit year)
 19//	MM   (month, or minutes when following an hour token + colon)
 20//	mm   (minutes, explicit)
 21//	DD   (day)
 22//	HH   (24-hour), hh (12-hour, zero-padded)
 23//	SS, ss (seconds)
 24//	AM, PM (meridiem marker)
 25const (
 26	DateFormatISO = "YYYY-MM-DD HH:MM"
 27	DateFormatUS  = "MM/DD/YYYY hh:MM AM"
 28	DateFormatEU  = "DD/MM/YYYY HH:MM"
 29)
 30
 31// Account stores the configuration for a single email account.
 32type Account struct {
 33	ID              string `json:"id"`
 34	Name            string `json:"name"`
 35	Email           string `json:"email"`
 36	Password        string `json:"-"`                // "-" prevents the password from being saved to config.json
 37	ServiceProvider string `json:"service_provider"` // "gmail", "outlook", "icloud", or "custom"
 38	// FetchEmail is the single email address for which messages should be fetched.
 39	// If empty, it will default to `Email` when accounts are added.
 40	FetchEmail string `json:"fetch_email,omitempty"`
 41	// SendAsEmail controls the visible From header on outgoing mail.
 42	// If empty, it defaults to FetchEmail, then Email.
 43	SendAsEmail string `json:"send_as_email,omitempty"`
 44
 45	// Custom server settings (used when ServiceProvider is "custom")
 46	IMAPServer string `json:"imap_server,omitempty"`
 47	IMAPPort   int    `json:"imap_port,omitempty"`
 48	SMTPServer string `json:"smtp_server,omitempty"`
 49	SMTPPort   int    `json:"smtp_port,omitempty"`
 50	Insecure   bool   `json:"insecure,omitempty"`
 51
 52	// S/MIME settings
 53	SMIMECert          string `json:"smime_cert,omitempty"`            // Path to the public certificate PEM
 54	SMIMEKey           string `json:"smime_key,omitempty"`             // Path to the private key PEM
 55	SMIMESignByDefault bool   `json:"smime_sign_by_default,omitempty"` // Whether to enable S/MIME signing by default
 56
 57	// PGP settings
 58	PGPPublicKey     string `json:"pgp_public_key,omitempty"`      // Path to public key (.asc or .gpg)
 59	PGPPrivateKey    string `json:"pgp_private_key,omitempty"`     // Path to private key (.asc or .gpg)
 60	PGPKeySource     string `json:"pgp_key_source,omitempty"`      // "file" (default) or "yubikey" for hardware key
 61	PGPPIN           string `json:"-"`                             // YubiKey PIN (stored in keyring, not JSON)
 62	PGPSignByDefault bool   `json:"pgp_sign_by_default,omitempty"` // Auto-sign outgoing emails
 63
 64	// OAuth2 settings
 65	AuthMethod string `json:"auth_method,omitempty"` // "password" (default) or "oauth2"
 66
 67	// Multi-protocol settings
 68	Protocol     string `json:"protocol,omitempty"`      // "imap" (default), "jmap", or "pop3"
 69	JMAPEndpoint string `json:"jmap_endpoint,omitempty"` // JMAP session URL (for protocol=jmap)
 70	POP3Server   string `json:"pop3_server,omitempty"`   // POP3 server hostname (for protocol=pop3)
 71	POP3Port     int    `json:"pop3_port,omitempty"`     // POP3 server port (for protocol=pop3)
 72}
 73
 74// MailingList represents a named group of email addresses.
 75type MailingList struct {
 76	Name      string   `json:"name"`
 77	Addresses []string `json:"addresses"`
 78}
 79
 80// Config stores the user's email configuration with multiple accounts.
 81type Config struct {
 82	Accounts             []Account     `json:"accounts"`
 83	DisableImages        bool          `json:"disable_images,omitempty"`
 84	HideTips             bool          `json:"hide_tips,omitempty"`
 85	DisableNotifications bool          `json:"disable_notifications,omitempty"`
 86	Theme                string        `json:"theme,omitempty"`
 87	MailingLists         []MailingList `json:"mailing_lists,omitempty"`
 88	DateFormat           string        `json:"date_format,omitempty"`
 89}
 90
 91// GetDateFormat returns the Go time reference layout translated from the
 92// user's configured human-readable format. Defaults to EU when unset.
 93func (c *Config) GetDateFormat() string {
 94	f := c.DateFormat
 95	if f == "" {
 96		f = DateFormatEU
 97	}
 98	return translateDateFormat(f)
 99}
100
101// translateDateFormat converts a human-readable format string (e.g.
102// "DD/MM/YYYY HH:MM") into a Go reference-time layout usable by
103// time.Format. MM is disambiguated by context: when it directly follows
104// an hour token plus ":", it maps to minutes; otherwise to month.
105func translateDateFormat(f string) string {
106	var b strings.Builder
107	i := 0
108	for i < len(f) {
109		rest := f[i:]
110		switch {
111		case strings.HasPrefix(rest, "YYYY"):
112			b.WriteString("2006")
113			i += 4
114		case strings.HasPrefix(rest, "YY"):
115			b.WriteString("06")
116			i += 2
117		case strings.HasPrefix(rest, "DD"):
118			b.WriteString("02")
119			i += 2
120		case strings.HasPrefix(rest, "HH"):
121			b.WriteString("15")
122			i += 2
123		case strings.HasPrefix(rest, "hh"):
124			b.WriteString("03")
125			i += 2
126		case strings.HasPrefix(rest, "mm"):
127			b.WriteString("04")
128			i += 2
129		case strings.HasPrefix(rest, "SS"), strings.HasPrefix(rest, "ss"):
130			b.WriteString("05")
131			i += 2
132		case strings.HasPrefix(rest, "MM"):
133			cur := b.String()
134			if strings.HasSuffix(cur, "15:") || strings.HasSuffix(cur, "03:") {
135				b.WriteString("04")
136			} else {
137				b.WriteString("01")
138			}
139			i += 2
140		case strings.HasPrefix(rest, "AM"), strings.HasPrefix(rest, "PM"):
141			b.WriteString("PM")
142			i += 2
143		default:
144			b.WriteByte(f[i])
145			i++
146		}
147	}
148	return b.String()
149}
150
151// GetIMAPServer returns the IMAP server address for the account.
152func (a *Account) GetIMAPServer() string {
153	switch a.ServiceProvider {
154	case "gmail":
155		return "imap.gmail.com"
156	case "outlook":
157		return "outlook.office365.com"
158	case "icloud":
159		return "imap.mail.me.com"
160	case "custom":
161		return a.IMAPServer
162	default:
163		return ""
164	}
165}
166
167// GetIMAPPort returns the IMAP port for the account.
168func (a *Account) GetIMAPPort() int {
169	switch a.ServiceProvider {
170	case "gmail", "outlook", "icloud":
171		return 993
172	case "custom":
173		if a.IMAPPort != 0 {
174			return a.IMAPPort
175		}
176		return 993 // Default IMAP SSL port
177	default:
178		return 993
179	}
180}
181
182// GetSMTPServer returns the SMTP server address for the account.
183func (a *Account) GetSMTPServer() string {
184	switch a.ServiceProvider {
185	case "gmail":
186		return "smtp.gmail.com"
187	case "outlook":
188		return "smtp.office365.com"
189	case "icloud":
190		return "smtp.mail.me.com"
191	case "custom":
192		return a.SMTPServer
193	default:
194		return ""
195	}
196}
197
198// GetSMTPPort returns the SMTP port for the account.
199func (a *Account) GetSMTPPort() int {
200	switch a.ServiceProvider {
201	case "gmail", "outlook", "icloud":
202		return 587
203	case "custom":
204		if a.SMTPPort != 0 {
205			return a.SMTPPort
206		}
207		return 587 // Default SMTP TLS port
208	default:
209		return 587
210	}
211}
212
213// GetFetchEmail returns the configured fetch identity, falling back to Email.
214func (a *Account) GetFetchEmail() string {
215	if a.FetchEmail != "" {
216		return a.FetchEmail
217	}
218	return a.Email
219}
220
221// GetSendAsEmail returns the visible sender address for outgoing mail.
222func (a *Account) GetSendAsEmail() string {
223	if a.SendAsEmail != "" {
224		return a.SendAsEmail
225	}
226	return a.GetFetchEmail()
227}
228
229// FormatFromHeader returns the display-ready From header value.
230func (a *Account) FormatFromHeader() string {
231	sendAs := a.GetSendAsEmail()
232	if a.Name != "" && sendAs != "" {
233		return fmt.Sprintf("%s <%s>", a.Name, sendAs)
234	}
235	return sendAs
236}
237
238// GetPOP3Server returns the POP3 server address for the account.
239func (a *Account) GetPOP3Server() string {
240	if a.POP3Server != "" {
241		return a.POP3Server
242	}
243	return ""
244}
245
246// GetPOP3Port returns the POP3 port for the account.
247func (a *Account) GetPOP3Port() int {
248	if a.POP3Port != 0 {
249		return a.POP3Port
250	}
251	return 995 // Default POP3 SSL port
252}
253
254// GetConfigDir returns the path to the configuration directory (exported).
255func GetConfigDir() (string, error) {
256	return configDir()
257}
258
259// configDir returns the path to the configuration directory (internal).
260func configDir() (string, error) {
261	home, err := os.UserHomeDir()
262	if err != nil {
263		return "", err
264	}
265	return filepath.Join(home, ".config", "matcha"), nil
266}
267
268// GetCacheDir returns the path to the cache directory (exported).
269func GetCacheDir() (string, error) {
270	return cacheDir()
271}
272
273// cacheDir returns the path to the cache directory (internal).
274func cacheDir() (string, error) {
275	home, err := os.UserHomeDir()
276	if err != nil {
277		return "", err
278	}
279	return filepath.Join(home, ".cache", "matcha"), nil
280}
281
282// MigrateCacheFiles moves cache files from ~/.config/matcha/ to ~/.cache/matcha/ if needed.
283// This is a one-time migration for existing installations.
284func MigrateCacheFiles() error {
285	src, err := configDir()
286	if err != nil {
287		return err
288	}
289	dst, err := cacheDir()
290	if err != nil {
291		return err
292	}
293	if err := os.MkdirAll(dst, 0700); err != nil {
294		return err
295	}
296
297	// Files to migrate
298	files := []string{"email_cache.json", "contacts.json", "drafts.json", "folder_cache.json"}
299	for _, f := range files {
300		oldPath := filepath.Join(src, f)
301		newPath := filepath.Join(dst, f)
302		if _, err := os.Stat(oldPath); err == nil {
303			// Only migrate if destination doesn't already exist
304			if _, err := os.Stat(newPath); err != nil {
305				if err := os.Rename(oldPath, newPath); err != nil {
306					return err
307				}
308			}
309		}
310	}
311
312	// Migrate folder_emails directory
313	oldDir := filepath.Join(src, "folder_emails")
314	newDir := filepath.Join(dst, "folder_emails")
315	if info, err := os.Stat(oldDir); err == nil && info.IsDir() {
316		if _, err := os.Stat(newDir); err != nil {
317			if err := os.Rename(oldDir, newDir); err != nil {
318				return err
319			}
320		}
321	}
322
323	return nil
324}
325
326// configFile returns the full path to the configuration file.
327func configFile() (string, error) {
328	dir, err := configDir()
329	if err != nil {
330		return "", err
331	}
332	return filepath.Join(dir, "config.json"), nil
333}
334
335// secureDiskAccount includes the Password field in JSON when secure mode is active.
336type secureDiskAccount struct {
337	ID                 string `json:"id"`
338	Name               string `json:"name"`
339	Email              string `json:"email"`
340	Password           string `json:"password,omitempty"`
341	ServiceProvider    string `json:"service_provider"`
342	FetchEmail         string `json:"fetch_email,omitempty"`
343	SendAsEmail        string `json:"send_as_email,omitempty"`
344	IMAPServer         string `json:"imap_server,omitempty"`
345	IMAPPort           int    `json:"imap_port,omitempty"`
346	SMTPServer         string `json:"smtp_server,omitempty"`
347	SMTPPort           int    `json:"smtp_port,omitempty"`
348	Insecure           bool   `json:"insecure,omitempty"`
349	SMIMECert          string `json:"smime_cert,omitempty"`
350	SMIMEKey           string `json:"smime_key,omitempty"`
351	SMIMESignByDefault bool   `json:"smime_sign_by_default,omitempty"`
352	PGPPublicKey       string `json:"pgp_public_key,omitempty"`
353	PGPPrivateKey      string `json:"pgp_private_key,omitempty"`
354	PGPKeySource       string `json:"pgp_key_source,omitempty"`
355	PGPPIN             string `json:"pgp_pin,omitempty"`
356	PGPSignByDefault   bool   `json:"pgp_sign_by_default,omitempty"`
357	AuthMethod         string `json:"auth_method,omitempty"`
358	Protocol           string `json:"protocol,omitempty"`
359	JMAPEndpoint       string `json:"jmap_endpoint,omitempty"`
360	POP3Server         string `json:"pop3_server,omitempty"`
361	POP3Port           int    `json:"pop3_port,omitempty"`
362}
363
364type secureDiskConfig struct {
365	Accounts             []secureDiskAccount `json:"accounts"`
366	DisableImages        bool                `json:"disable_images,omitempty"`
367	HideTips             bool                `json:"hide_tips,omitempty"`
368	DisableNotifications bool                `json:"disable_notifications,omitempty"`
369	Theme                string              `json:"theme,omitempty"`
370	MailingLists         []MailingList       `json:"mailing_lists,omitempty"`
371	DateFormat           string              `json:"date_format,omitempty"`
372}
373
374// SaveConfig saves the given configuration to the config file and passwords to the keyring.
375func SaveConfig(config *Config) error {
376	secureMode := GetSessionKey() != nil
377
378	if !secureMode {
379		// Save passwords and PGP PINs to the OS keyring before writing the JSON file
380		for _, acc := range config.Accounts {
381			if acc.Password != "" {
382				_ = keyring.Set(keyringServiceName, acc.Email, acc.Password)
383			}
384			if acc.PGPPIN != "" && acc.PGPKeySource == "yubikey" {
385				_ = keyring.Set(keyringServiceName, acc.Email+":pgp-pin", acc.PGPPIN)
386			}
387		}
388	}
389
390	path, err := configFile()
391	if err != nil {
392		return err
393	}
394	if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
395		return err
396	}
397
398	var data []byte
399	if secureMode {
400		// In secure mode, include passwords in the JSON (they'll be encrypted on disk)
401		sdc := secureDiskConfig{
402			DisableImages:        config.DisableImages,
403			HideTips:             config.HideTips,
404			DisableNotifications: config.DisableNotifications,
405			Theme:                config.Theme,
406			MailingLists:         config.MailingLists,
407			DateFormat:           config.DateFormat,
408		}
409		for _, acc := range config.Accounts {
410			sdc.Accounts = append(sdc.Accounts, secureDiskAccount{
411				ID:                 acc.ID,
412				Name:               acc.Name,
413				Email:              acc.Email,
414				Password:           acc.Password,
415				ServiceProvider:    acc.ServiceProvider,
416				FetchEmail:         acc.FetchEmail,
417				SendAsEmail:        acc.SendAsEmail,
418				IMAPServer:         acc.IMAPServer,
419				IMAPPort:           acc.IMAPPort,
420				SMTPServer:         acc.SMTPServer,
421				SMTPPort:           acc.SMTPPort,
422				Insecure:           acc.Insecure,
423				SMIMECert:          acc.SMIMECert,
424				SMIMEKey:           acc.SMIMEKey,
425				SMIMESignByDefault: acc.SMIMESignByDefault,
426				PGPPublicKey:       acc.PGPPublicKey,
427				PGPPrivateKey:      acc.PGPPrivateKey,
428				PGPKeySource:       acc.PGPKeySource,
429				PGPPIN:             acc.PGPPIN,
430				PGPSignByDefault:   acc.PGPSignByDefault,
431				AuthMethod:         acc.AuthMethod,
432				Protocol:           acc.Protocol,
433				JMAPEndpoint:       acc.JMAPEndpoint,
434				POP3Server:         acc.POP3Server,
435				POP3Port:           acc.POP3Port,
436			})
437		}
438		data, err = json.MarshalIndent(sdc, "", "  ")
439	} else {
440		data, err = json.MarshalIndent(config, "", "  ")
441	}
442	if err != nil {
443		return err
444	}
445	return SecureWriteFile(path, data, 0600)
446}
447
448// LoadConfig loads the configuration from the config file and passwords from the keyring.
449// It automatically migrates plain-text passwords to the OS keyring if they exist.
450func LoadConfig() (*Config, error) {
451	path, err := configFile()
452	if err != nil {
453		return nil, err
454	}
455	data, err := SecureReadFile(path)
456	if err != nil {
457		return nil, err
458	}
459
460	secureMode := GetSessionKey() != nil
461
462	var config Config
463	var needsMigration bool
464
465	type rawAccount struct {
466		ID                 string `json:"id"`
467		Name               string `json:"name"`
468		Email              string `json:"email"`
469		Password           string `json:"password,omitempty"`
470		ServiceProvider    string `json:"service_provider"`
471		FetchEmail         string `json:"fetch_email,omitempty"`
472		SendAsEmail        string `json:"send_as_email,omitempty"`
473		IMAPServer         string `json:"imap_server,omitempty"`
474		IMAPPort           int    `json:"imap_port,omitempty"`
475		SMTPServer         string `json:"smtp_server,omitempty"`
476		SMTPPort           int    `json:"smtp_port,omitempty"`
477		Insecure           bool   `json:"insecure,omitempty"`
478		SMIMECert          string `json:"smime_cert,omitempty"`
479		SMIMEKey           string `json:"smime_key,omitempty"`
480		SMIMESignByDefault bool   `json:"smime_sign_by_default,omitempty"`
481		PGPPublicKey       string `json:"pgp_public_key,omitempty"`
482		PGPPrivateKey      string `json:"pgp_private_key,omitempty"`
483		PGPKeySource       string `json:"pgp_key_source,omitempty"`
484		PGPPIN             string `json:"pgp_pin,omitempty"`
485		PGPSignByDefault   bool   `json:"pgp_sign_by_default,omitempty"`
486		AuthMethod         string `json:"auth_method,omitempty"`
487		Protocol           string `json:"protocol,omitempty"`
488		JMAPEndpoint       string `json:"jmap_endpoint,omitempty"`
489		POP3Server         string `json:"pop3_server,omitempty"`
490		POP3Port           int    `json:"pop3_port,omitempty"`
491	}
492	type diskConfig struct {
493		Accounts             []rawAccount  `json:"accounts"`
494		DisableImages        bool          `json:"disable_images,omitempty"`
495		HideTips             bool          `json:"hide_tips,omitempty"`
496		DisableNotifications bool          `json:"disable_notifications,omitempty"`
497		Theme                string        `json:"theme,omitempty"`
498		MailingLists         []MailingList `json:"mailing_lists,omitempty"`
499		DateFormat           string        `json:"date_format,omitempty"`
500	}
501
502	var raw diskConfig
503	if err := json.Unmarshal(data, &raw); err != nil {
504		var legacyConfig legacyConfigFormat
505		if legacyErr := json.Unmarshal(data, &legacyConfig); legacyErr == nil && legacyConfig.Email != "" {
506			config = Config{
507				Accounts: []Account{
508					{
509						ID:              uuid.New().String(),
510						Name:            legacyConfig.Name,
511						Email:           legacyConfig.Email,
512						Password:        legacyConfig.Password,
513						ServiceProvider: legacyConfig.ServiceProvider,
514						FetchEmail:      legacyConfig.Email,
515					},
516				},
517			}
518			// SaveConfig automatically pushes the password to the keyring and strips it from JSON
519			if saveErr := SaveConfig(&config); saveErr != nil {
520				return nil, saveErr
521			}
522			return &config, nil
523		}
524		return nil, err
525	}
526
527	config.DisableImages = raw.DisableImages
528	config.HideTips = raw.HideTips
529	config.DisableNotifications = raw.DisableNotifications
530	config.Theme = raw.Theme
531	config.MailingLists = raw.MailingLists
532	config.DateFormat = raw.DateFormat
533	for _, rawAcc := range raw.Accounts {
534		acc := Account{
535			ID:                 rawAcc.ID,
536			Name:               rawAcc.Name,
537			Email:              rawAcc.Email,
538			ServiceProvider:    rawAcc.ServiceProvider,
539			FetchEmail:         rawAcc.FetchEmail,
540			SendAsEmail:        rawAcc.SendAsEmail,
541			IMAPServer:         rawAcc.IMAPServer,
542			IMAPPort:           rawAcc.IMAPPort,
543			SMTPServer:         rawAcc.SMTPServer,
544			SMTPPort:           rawAcc.SMTPPort,
545			Insecure:           rawAcc.Insecure,
546			SMIMECert:          rawAcc.SMIMECert,
547			SMIMEKey:           rawAcc.SMIMEKey,
548			SMIMESignByDefault: rawAcc.SMIMESignByDefault,
549			PGPPublicKey:       rawAcc.PGPPublicKey,
550			PGPPrivateKey:      rawAcc.PGPPrivateKey,
551			PGPKeySource:       rawAcc.PGPKeySource,
552			PGPSignByDefault:   rawAcc.PGPSignByDefault,
553			AuthMethod:         rawAcc.AuthMethod,
554			Protocol:           rawAcc.Protocol,
555			JMAPEndpoint:       rawAcc.JMAPEndpoint,
556			POP3Server:         rawAcc.POP3Server,
557			POP3Port:           rawAcc.POP3Port,
558		}
559
560		if secureMode {
561			// In secure mode, passwords and PINs are stored in the encrypted config JSON
562			acc.Password = rawAcc.Password
563			acc.PGPPIN = rawAcc.PGPPIN
564		} else if rawAcc.Password != "" {
565			// Found a plain-text password! Move it to the OS Keyring.
566			_ = keyring.Set(keyringServiceName, rawAcc.Email, rawAcc.Password)
567			acc.Password = rawAcc.Password
568			needsMigration = true
569		} else {
570			// No plaintext password in JSON, fetch from Keyring as normal.
571			if pwd, err := keyring.Get(keyringServiceName, acc.Email); err == nil {
572				acc.Password = pwd
573			}
574		}
575
576		if !secureMode {
577			// Load YubiKey PIN from keyring if using YubiKey
578			if acc.PGPKeySource == "yubikey" {
579				if pin, err := keyring.Get(keyringServiceName, acc.Email+":pgp-pin"); err == nil {
580					acc.PGPPIN = pin
581				}
582			}
583		}
584
585		config.Accounts = append(config.Accounts, acc)
586	}
587
588	if needsMigration {
589		if saveErr := SaveConfig(&config); saveErr != nil {
590			return nil, saveErr
591		}
592	}
593
594	return &config, nil
595}
596
597// legacyConfigFormat represents the old single-account configuration format.
598type legacyConfigFormat struct {
599	ServiceProvider string `json:"service_provider"`
600	Email           string `json:"email"`
601	Password        string `json:"password"`
602	Name            string `json:"name"`
603}
604
605// AddAccount adds a new account to the configuration.
606func (c *Config) AddAccount(account Account) {
607	if account.ID == "" {
608		account.ID = uuid.New().String()
609	}
610	// Ensure FetchEmail defaults to the login Email if not explicitly set.
611	if account.FetchEmail == "" && account.Email != "" {
612		account.FetchEmail = account.Email
613	}
614	c.Accounts = append(c.Accounts, account)
615}
616
617// RemoveAccount removes an account by its ID and deletes its password from the keyring.
618func (c *Config) RemoveAccount(id string) bool {
619	for i, acc := range c.Accounts {
620		if acc.ID == id {
621			// Delete password from OS Keyring when account is removed
622			_ = keyring.Delete(keyringServiceName, acc.Email)
623			// Delete PGP PIN from OS Keyring if present
624			_ = keyring.Delete(keyringServiceName, acc.Email+":pgp-pin")
625
626			c.Accounts = append(c.Accounts[:i], c.Accounts[i+1:]...)
627			return true
628		}
629	}
630	return false
631}
632
633// GetAccountByID returns an account by its ID.
634func (c *Config) GetAccountByID(id string) *Account {
635	for i := range c.Accounts {
636		if c.Accounts[i].ID == id {
637			return &c.Accounts[i]
638		}
639	}
640	return nil
641}
642
643// GetAccountByEmail returns an account by its email address.
644func (c *Config) GetAccountByEmail(email string) *Account {
645	for i := range c.Accounts {
646		if c.Accounts[i].Email == email {
647			return &c.Accounts[i]
648		}
649	}
650	return nil
651}
652
653// HasAccounts returns true if there are any configured accounts.
654func (c *Config) HasAccounts() bool {
655	return len(c.Accounts) > 0
656}
657
658// GetFirstAccount returns the first account or nil if none exist.
659func (c *Config) GetFirstAccount() *Account {
660	if len(c.Accounts) > 0 {
661		return &c.Accounts[0]
662	}
663	return nil
664}
665
666// EnsurePGPDir creates the PGP keys directory if it doesn't exist.
667func EnsurePGPDir() error {
668	dir, err := configDir()
669	if err != nil {
670		return err
671	}
672	pgpDir := filepath.Join(dir, "pgp")
673	return os.MkdirAll(pgpDir, 0700)
674}