Skip to content
Draft
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
5 changes: 5 additions & 0 deletions backend/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ type Attachment struct {
IsSMIMESignature bool
SMIMEVerified bool
IsSMIMEEncrypted bool
IsPGPSignature bool
PGPVerified bool
IsPGPEncrypted bool
}

// Folder represents a mailbox/folder.
Expand All @@ -105,6 +108,8 @@ type OutgoingEmail struct {
References []string
SignSMIME bool
EncryptSMIME bool
SignPGP bool
EncryptPGP bool
}

// NotifyType indicates the kind of notification event.
Expand Down
1 change: 1 addition & 0 deletions backend/imap/imap.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ func (p *Provider) SendEmail(_ context.Context, msg *backend.OutgoingEmail) erro
msg.Images, msg.Attachments,
msg.InReplyTo, msg.References,
msg.SignSMIME, msg.EncryptSMIME,
msg.SignPGP, msg.EncryptPGP,
)
}

Expand Down
1 change: 1 addition & 0 deletions backend/pop3/pop3.go
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ func (p *Provider) SendEmail(_ context.Context, msg *backend.OutgoingEmail) erro
msg.Images, msg.Attachments,
msg.InReplyTo, msg.References,
msg.SignSMIME, msg.EncryptSMIME,
msg.SignPGP, msg.EncryptPGP,
)
}

Expand Down
117 changes: 79 additions & 38 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,14 @@ type Account struct {
SMIMEKey string `json:"smime_key,omitempty"` // Path to the private key PEM
SMIMESignByDefault bool `json:"smime_sign_by_default,omitempty"` // Whether to enable S/MIME signing by default

// PGP settings
PGPPublicKey string `json:"pgp_public_key,omitempty"` // Path to public key (.asc or .gpg)
PGPPrivateKey string `json:"pgp_private_key,omitempty"` // Path to private key (.asc or .gpg)
PGPKeySource string `json:"pgp_key_source,omitempty"` // "file" (default) or "yubikey" for hardware key
PGPPIN string `json:"-"` // YubiKey PIN (stored in keyring, not JSON)
PGPSignByDefault bool `json:"pgp_sign_by_default,omitempty"` // Auto-sign outgoing emails
PGPEncryptByDefault bool `json:"pgp_encrypt_by_default,omitempty"` // Auto-encrypt when recipient keys available

// OAuth2 settings
AuthMethod string `json:"auth_method,omitempty"` // "password" (default) or "oauth2"

Expand Down Expand Up @@ -159,13 +167,17 @@ func configFile() (string, error) {

// SaveConfig saves the given configuration to the config file and passwords to the keyring.
func SaveConfig(config *Config) error {
// Save passwords to the OS keyring before writing the JSON file
// Save passwords and PGP PINs to the OS keyring before writing the JSON file
for _, acc := range config.Accounts {
if acc.Password != "" {
// We ignore the error here because some environments (like headless CI)
// might not have a keyring service, but we still want to save the rest of the config.
_ = keyring.Set(keyringServiceName, acc.Email, acc.Password)
}
// Save YubiKey PIN if present
if acc.PGPPIN != "" && acc.PGPKeySource == "yubikey" {
_ = keyring.Set(keyringServiceName, acc.Email+":pgp-pin", acc.PGPPIN)
}
}

path, err := configFile()
Expand Down Expand Up @@ -198,25 +210,30 @@ func LoadConfig() (*Config, error) {
var needsMigration bool

type rawAccount struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
Password string `json:"password,omitempty"`
ServiceProvider string `json:"service_provider"`
FetchEmail string `json:"fetch_email,omitempty"`
IMAPServer string `json:"imap_server,omitempty"`
IMAPPort int `json:"imap_port,omitempty"`
SMTPServer string `json:"smtp_server,omitempty"`
SMTPPort int `json:"smtp_port,omitempty"`
Insecure bool `json:"insecure,omitempty"`
SMIMECert string `json:"smime_cert,omitempty"`
SMIMEKey string `json:"smime_key,omitempty"`
SMIMESignByDefault bool `json:"smime_sign_by_default,omitempty"`
AuthMethod string `json:"auth_method,omitempty"`
Protocol string `json:"protocol,omitempty"`
JMAPEndpoint string `json:"jmap_endpoint,omitempty"`
POP3Server string `json:"pop3_server,omitempty"`
POP3Port int `json:"pop3_port,omitempty"`
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
Password string `json:"password,omitempty"`
ServiceProvider string `json:"service_provider"`
FetchEmail string `json:"fetch_email,omitempty"`
IMAPServer string `json:"imap_server,omitempty"`
IMAPPort int `json:"imap_port,omitempty"`
SMTPServer string `json:"smtp_server,omitempty"`
SMTPPort int `json:"smtp_port,omitempty"`
Insecure bool `json:"insecure,omitempty"`
SMIMECert string `json:"smime_cert,omitempty"`
SMIMEKey string `json:"smime_key,omitempty"`
SMIMESignByDefault bool `json:"smime_sign_by_default,omitempty"`
PGPPublicKey string `json:"pgp_public_key,omitempty"`
PGPPrivateKey string `json:"pgp_private_key,omitempty"`
PGPKeySource string `json:"pgp_key_source,omitempty"`
PGPSignByDefault bool `json:"pgp_sign_by_default,omitempty"`
PGPEncryptByDefault bool `json:"pgp_encrypt_by_default,omitempty"`
AuthMethod string `json:"auth_method,omitempty"`
Protocol string `json:"protocol,omitempty"`
JMAPEndpoint string `json:"jmap_endpoint,omitempty"`
POP3Server string `json:"pop3_server,omitempty"`
POP3Port int `json:"pop3_port,omitempty"`
}
type diskConfig struct {
Accounts []rawAccount `json:"accounts"`
Expand Down Expand Up @@ -259,24 +276,29 @@ func LoadConfig() (*Config, error) {
config.MailingLists = raw.MailingLists
for _, rawAcc := range raw.Accounts {
acc := Account{
ID: rawAcc.ID,
Name: rawAcc.Name,
Email: rawAcc.Email,
ServiceProvider: rawAcc.ServiceProvider,
FetchEmail: rawAcc.FetchEmail,
IMAPServer: rawAcc.IMAPServer,
IMAPPort: rawAcc.IMAPPort,
SMTPServer: rawAcc.SMTPServer,
SMTPPort: rawAcc.SMTPPort,
Insecure: rawAcc.Insecure,
SMIMECert: rawAcc.SMIMECert,
SMIMEKey: rawAcc.SMIMEKey,
SMIMESignByDefault: rawAcc.SMIMESignByDefault,
AuthMethod: rawAcc.AuthMethod,
Protocol: rawAcc.Protocol,
JMAPEndpoint: rawAcc.JMAPEndpoint,
POP3Server: rawAcc.POP3Server,
POP3Port: rawAcc.POP3Port,
ID: rawAcc.ID,
Name: rawAcc.Name,
Email: rawAcc.Email,
ServiceProvider: rawAcc.ServiceProvider,
FetchEmail: rawAcc.FetchEmail,
IMAPServer: rawAcc.IMAPServer,
IMAPPort: rawAcc.IMAPPort,
SMTPServer: rawAcc.SMTPServer,
SMTPPort: rawAcc.SMTPPort,
Insecure: rawAcc.Insecure,
SMIMECert: rawAcc.SMIMECert,
SMIMEKey: rawAcc.SMIMEKey,
SMIMESignByDefault: rawAcc.SMIMESignByDefault,
PGPPublicKey: rawAcc.PGPPublicKey,
PGPPrivateKey: rawAcc.PGPPrivateKey,
PGPKeySource: rawAcc.PGPKeySource,
PGPSignByDefault: rawAcc.PGPSignByDefault,
PGPEncryptByDefault: rawAcc.PGPEncryptByDefault,
AuthMethod: rawAcc.AuthMethod,
Protocol: rawAcc.Protocol,
JMAPEndpoint: rawAcc.JMAPEndpoint,
POP3Server: rawAcc.POP3Server,
POP3Port: rawAcc.POP3Port,
}

if rawAcc.Password != "" {
Expand All @@ -291,6 +313,13 @@ func LoadConfig() (*Config, error) {
}
}

// Load YubiKey PIN from keyring if using YubiKey
if acc.PGPKeySource == "yubikey" {
if pin, err := keyring.Get(keyringServiceName, acc.Email+":pgp-pin"); err == nil {
acc.PGPPIN = pin
}
}

config.Accounts = append(config.Accounts, acc)
}

Expand Down Expand Up @@ -329,6 +358,8 @@ func (c *Config) RemoveAccount(id string) bool {
if acc.ID == id {
// Delete password from OS Keyring when account is removed
_ = keyring.Delete(keyringServiceName, acc.Email)
// Delete PGP PIN from OS Keyring if present
_ = keyring.Delete(keyringServiceName, acc.Email+":pgp-pin")

c.Accounts = append(c.Accounts[:i], c.Accounts[i+1:]...)
return true
Expand Down Expand Up @@ -369,3 +400,13 @@ func (c *Config) GetFirstAccount() *Account {
}
return nil
}

// EnsurePGPDir creates the PGP keys directory if it doesn't exist.
func EnsurePGPDir() error {
dir, err := configDir()
if err != nil {
return err
}
pgpDir := filepath.Join(dir, "pgp")
return os.MkdirAll(pgpDir, 0700)
}
Loading