fix: add TLS session resumption (#1304)

Mohamed Mahmoud created

## What?

Configured `TLS` session resumption for `IMAP` and `SMTP`.

## Why?

Reduce reconnect latency by avoiding full `TLS` handshakes.

Closes #1174

Change summary

config/config.go      | 19 +++++++++++++++++++
config/config_test.go |  2 ++
fetcher/fetcher.go    |  6 ++++++
main.go               |  2 ++
sender/sender.go      | 12 ++++++++++++
5 files changed, 41 insertions(+)

Detailed changes

config/config.go 🔗

@@ -1,12 +1,14 @@
 package config
 
 import (
+	"crypto/tls"
 	"encoding/json"
 	"fmt"
 	"log"
 	"os"
 	"path/filepath"
 	"strings"
+	"sync"
 
 	"github.com/google/uuid"
 	"github.com/zalando/go-keyring"
@@ -29,6 +31,11 @@ const (
 	DateFormatEU  = "DD/MM/YYYY HH:MM"
 )
 
+type SessionCache struct {
+	once  sync.Once
+	cache tls.ClientSessionCache
+}
+
 // Account stores the configuration for a single email account.
 type Account struct {
 	ID              string `json:"id"`
@@ -46,6 +53,8 @@ type Account struct {
 	// regardless of which address they were delivered to.
 	CatchAll bool `json:"catch_all,omitempty"`
 
+	SC *SessionCache `json:"-"` // "-" prevents the SessionCache from being saved to config.json
+
 	// Custom server settings (used when ServiceProvider is "custom")
 	IMAPServer string `json:"imap_server,omitempty"`
 	IMAPPort   int    `json:"imap_port,omitempty"`
@@ -229,6 +238,14 @@ func (a *Account) GetSMTPServer() string {
 	}
 }
 
+func (a *Account) GetClientSessionCache() tls.ClientSessionCache {
+	a.SC.once.Do(func() {
+		a.SC.cache = tls.NewLRUClientSessionCache(64)
+	})
+
+	return a.SC.cache
+}
+
 // GetSMTPPort returns the SMTP port for the account.
 func (a *Account) GetSMTPPort() int {
 	switch a.ServiceProvider {
@@ -580,6 +597,7 @@ func LoadConfig() (*Config, error) {
 						Password:        legacyConfig.Password,
 						ServiceProvider: legacyConfig.ServiceProvider,
 						FetchEmail:      legacyConfig.Email,
+						SC:              &SessionCache{},
 					},
 				},
 			}
@@ -631,6 +649,7 @@ func LoadConfig() (*Config, error) {
 			POP3Server:         rawAcc.POP3Server,
 			POP3Port:           rawAcc.POP3Port,
 			CatchAll:           rawAcc.CatchAll,
+			SC:                 &SessionCache{},
 		}
 
 		// Validate PGPKeySource

config/config_test.go 🔗

@@ -32,6 +32,7 @@ func TestSaveAndLoadConfig(t *testing.T) {
 				Password:        "supersecret",
 				ServiceProvider: "gmail",
 				SendAsEmail:     "alias@example.com",
+				SC:              &SessionCache{},
 			},
 			{
 				ID:              "test-id-2",
@@ -44,6 +45,7 @@ func TestSaveAndLoadConfig(t *testing.T) {
 				SMTPServer:      "smtp.custom.com",
 				SMTPPort:        587,
 				CatchAll:        true,
+				SC:              &SessionCache{},
 			},
 		},
 	}

fetcher/fetcher.go 🔗

@@ -29,6 +29,7 @@ import (
 	"github.com/emersion/go-message/mail"
 	"github.com/emersion/go-pgpmail"
 	"github.com/floatpane/matcha/config"
+	"github.com/floatpane/matcha/internal/loglevel"
 	"go.mozilla.org/pkcs7"
 	"golang.org/x/text/encoding"
 	"golang.org/x/text/encoding/ianaindex"
@@ -376,6 +377,11 @@ func connectWithOptions(account *config.Account, extraOpts *imapclient.Options)
 			ServerName:         imapServer,
 			InsecureSkipVerify: account.Insecure,
 			MinVersion:         tls.VersionTLS12,
+			ClientSessionCache: account.GetClientSessionCache(),
+			VerifyConnection: func(cs tls.ConnectionState) error {
+				loglevel.Debugf("IMAP TLS connection resumed: %t", cs.DidResume)
+				return nil
+			},
 		},
 	}
 	if extraOpts != nil {

main.go 🔗

@@ -385,6 +385,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				POP3Server:      msg.POP3Server,
 				POP3Port:        msg.POP3Port,
 				MaildirPath:     msg.MaildirPath,
+				SC:              &config.SessionCache{},
 			}
 
 			if msg.Provider == "custom" || msg.Protocol == "pop3" {
@@ -430,6 +431,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 					POP3Server:      msg.POP3Server,
 					POP3Port:        msg.POP3Port,
 					MaildirPath:     msg.MaildirPath,
+					SC:              &config.SessionCache{},
 				}
 
 				if msg.Provider == "custom" || msg.Protocol == "pop3" {

sender/sender.go 🔗

@@ -25,6 +25,7 @@ import (
 	"github.com/emersion/go-pgpmail"
 	"github.com/floatpane/matcha/clib"
 	"github.com/floatpane/matcha/config"
+	"github.com/floatpane/matcha/internal/loglevel"
 	"github.com/floatpane/matcha/pgp"
 	"github.com/yuin/goldmark"
 	"github.com/yuin/goldmark/ast"
@@ -671,6 +672,11 @@ func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody
 		ServerName:         smtpServer,
 		InsecureSkipVerify: account.Insecure,
 		MinVersion:         tls.VersionTLS12,
+		ClientSessionCache: account.GetClientSessionCache(),
+		VerifyConnection: func(cs tls.ConnectionState) error {
+			loglevel.Debugf("SMTP TLS connection resumed: %t", cs.DidResume)
+			return nil
+		},
 	}
 
 	var c *smtp.Client
@@ -881,10 +887,16 @@ func SendCalendarReply(account *config.Account, to []string, subject, plainBody
 
 	// Send via SMTP
 	addr := fmt.Sprintf("%s:%d", smtpServer, smtpPort)
+
 	tlsConfig := &tls.Config{
 		ServerName:         smtpServer,
 		InsecureSkipVerify: account.Insecure,
 		MinVersion:         tls.VersionTLS12,
+		ClientSessionCache: account.GetClientSessionCache(),
+		VerifyConnection: func(cs tls.ConnectionState) error {
+			loglevel.Debugf("SMTP TLS connection resumed: %t", cs.DidResume)
+			return nil
+		},
 	}
 
 	var c *smtp.Client