feat: multiple protocol support (#431)

Drew Smirnoff created

Change summary

backend/backend.go   | 135 ++++++++++
backend/factory.go   |  31 ++
backend/imap/imap.go | 147 +++++++++++
backend/jmap/jmap.go | 586 ++++++++++++++++++++++++++++++++++++++++++++++
backend/pop3/pop3.go | 407 +++++++++++++++++++++++++++++++
config/config.go     |  30 ++
go.mod               |   6 
go.sum               |  20 +
main.go              |  48 +++
tui/login.go         | 350 +++++++++++++++++---------
tui/messages.go      |  46 ++-
tui/settings.go      |  22 +
12 files changed, 1,670 insertions(+), 158 deletions(-)

Detailed changes

backend/backend.go 🔗

@@ -0,0 +1,135 @@
+// Package backend defines the Provider interface for multi-protocol email support.
+package backend
+
+import (
+	"context"
+	"errors"
+	"time"
+)
+
+// ErrNotSupported is returned when a provider does not support an operation.
+var ErrNotSupported = errors.New("operation not supported by this provider")
+
+// Provider is the unified interface that all email backends must implement.
+type Provider interface {
+	EmailReader
+	EmailWriter
+	EmailSender
+	FolderManager
+	Notifier
+	Close() error
+}
+
+// EmailReader fetches emails and their content.
+type EmailReader interface {
+	FetchEmails(ctx context.Context, folder string, limit, offset uint32) ([]Email, error)
+	FetchEmailBody(ctx context.Context, folder string, uid uint32) (string, []Attachment, error)
+	FetchAttachment(ctx context.Context, folder string, uid uint32, partID, encoding string) ([]byte, error)
+}
+
+// EmailWriter modifies email state.
+type EmailWriter interface {
+	MarkAsRead(ctx context.Context, folder string, uid uint32) error
+	DeleteEmail(ctx context.Context, folder string, uid uint32) error
+	ArchiveEmail(ctx context.Context, folder string, uid uint32) error
+	MoveEmail(ctx context.Context, uid uint32, srcFolder, dstFolder string) error
+}
+
+// EmailSender sends outgoing email.
+type EmailSender interface {
+	SendEmail(ctx context.Context, msg *OutgoingEmail) error
+}
+
+// FolderManager lists folders/mailboxes.
+type FolderManager interface {
+	FetchFolders(ctx context.Context) ([]Folder, error)
+}
+
+// Notifier provides real-time notifications for new email.
+type Notifier interface {
+	Watch(ctx context.Context, folder string) (<-chan NotifyEvent, func(), error)
+}
+
+// CapabilityProvider optionally reports what a backend can do.
+type CapabilityProvider interface {
+	Capabilities() Capabilities
+}
+
+// Email represents a single email message.
+type Email struct {
+	UID         uint32
+	From        string
+	To          []string
+	Subject     string
+	Body        string
+	Date        time.Time
+	IsRead      bool
+	MessageID   string
+	References  []string
+	Attachments []Attachment
+	AccountID   string
+}
+
+// Attachment holds data for an email attachment.
+type Attachment struct {
+	Filename         string
+	PartID           string
+	Data             []byte
+	Encoding         string
+	MIMEType         string
+	ContentID        string
+	Inline           bool
+	IsSMIMESignature bool
+	SMIMEVerified    bool
+	IsSMIMEEncrypted bool
+}
+
+// Folder represents a mailbox/folder.
+type Folder struct {
+	Name       string
+	Delimiter  string
+	Attributes []string
+}
+
+// OutgoingEmail contains everything needed to send an email.
+type OutgoingEmail struct {
+	To           []string
+	Cc           []string
+	Bcc          []string
+	Subject      string
+	PlainBody    string
+	HTMLBody     string
+	Images       map[string][]byte
+	Attachments  map[string][]byte
+	InReplyTo    string
+	References   []string
+	SignSMIME    bool
+	EncryptSMIME bool
+}
+
+// NotifyType indicates the kind of notification event.
+type NotifyType int
+
+const (
+	NotifyNewEmail NotifyType = iota
+	NotifyExpunge
+	NotifyFlagChange
+)
+
+// NotifyEvent is emitted by Watch() when something changes in a mailbox.
+type NotifyEvent struct {
+	Type      NotifyType
+	Folder    string
+	AccountID string
+}
+
+// Capabilities describes what a backend supports.
+type Capabilities struct {
+	CanSend         bool
+	CanMove         bool
+	CanArchive      bool
+	CanPush         bool
+	CanSearchServer bool
+	CanFetchFolders bool
+	SupportsSMIME   bool
+}

backend/factory.go 🔗

@@ -0,0 +1,31 @@
+package backend
+
+import (
+	"fmt"
+
+	"github.com/floatpane/matcha/config"
+)
+
+// NewFunc is the constructor signature for backend providers.
+type NewFunc func(account *config.Account) (Provider, error)
+
+var registry = map[string]NewFunc{}
+
+// RegisterBackend registers a backend constructor for a protocol name.
+func RegisterBackend(protocol string, fn NewFunc) {
+	registry[protocol] = fn
+}
+
+// New creates a Provider for the given account based on its Protocol field.
+// An empty protocol defaults to "imap".
+func New(account *config.Account) (Provider, error) {
+	protocol := account.Protocol
+	if protocol == "" {
+		protocol = "imap"
+	}
+	fn, ok := registry[protocol]
+	if !ok {
+		return nil, fmt.Errorf("unknown email protocol: %q", protocol)
+	}
+	return fn(account)
+}

backend/imap/imap.go 🔗

@@ -0,0 +1,147 @@
+// Package imap implements the backend.Provider interface by delegating
+// to the existing fetcher and sender packages.
+package imap
+
+import (
+	"context"
+
+	"github.com/floatpane/matcha/backend"
+	"github.com/floatpane/matcha/config"
+	"github.com/floatpane/matcha/fetcher"
+	"github.com/floatpane/matcha/sender"
+)
+
+func init() {
+	backend.RegisterBackend("imap", func(account *config.Account) (backend.Provider, error) {
+		return New(account)
+	})
+}
+
+// Provider wraps the existing fetcher/sender packages behind the backend.Provider interface.
+type Provider struct {
+	account *config.Account
+}
+
+// New creates a new IMAP provider.
+func New(account *config.Account) (*Provider, error) {
+	return &Provider{account: account}, nil
+}
+
+func (p *Provider) FetchEmails(_ context.Context, folder string, limit, offset uint32) ([]backend.Email, error) {
+	emails, err := fetcher.FetchMailboxEmails(p.account, folder, limit, offset)
+	if err != nil {
+		return nil, err
+	}
+	return toBackendEmails(emails), nil
+}
+
+func (p *Provider) FetchEmailBody(_ context.Context, folder string, uid uint32) (string, []backend.Attachment, error) {
+	body, atts, err := fetcher.FetchEmailBodyFromMailbox(p.account, folder, uid)
+	if err != nil {
+		return "", nil, err
+	}
+	return body, toBackendAttachments(atts), nil
+}
+
+func (p *Provider) FetchAttachment(_ context.Context, folder string, uid uint32, partID, encoding string) ([]byte, error) {
+	return fetcher.FetchAttachmentFromMailbox(p.account, folder, uid, partID, encoding)
+}
+
+func (p *Provider) MarkAsRead(_ context.Context, folder string, uid uint32) error {
+	return fetcher.MarkEmailAsReadInMailbox(p.account, folder, uid)
+}
+
+func (p *Provider) DeleteEmail(_ context.Context, folder string, uid uint32) error {
+	return fetcher.DeleteEmailFromMailbox(p.account, folder, uid)
+}
+
+func (p *Provider) ArchiveEmail(_ context.Context, folder string, uid uint32) error {
+	return fetcher.ArchiveEmailFromMailbox(p.account, folder, uid)
+}
+
+func (p *Provider) MoveEmail(_ context.Context, uid uint32, srcFolder, dstFolder string) error {
+	return fetcher.MoveEmailToFolder(p.account, uid, srcFolder, dstFolder)
+}
+
+func (p *Provider) SendEmail(_ context.Context, msg *backend.OutgoingEmail) error {
+	return sender.SendEmail(
+		p.account, msg.To, msg.Cc, msg.Bcc,
+		msg.Subject, msg.PlainBody, msg.HTMLBody,
+		msg.Images, msg.Attachments,
+		msg.InReplyTo, msg.References,
+		msg.SignSMIME, msg.EncryptSMIME,
+	)
+}
+
+func (p *Provider) FetchFolders(_ context.Context) ([]backend.Folder, error) {
+	folders, err := fetcher.FetchFolders(p.account)
+	if err != nil {
+		return nil, err
+	}
+	return toBackendFolders(folders), nil
+}
+
+func (p *Provider) Watch(_ context.Context, _ string) (<-chan backend.NotifyEvent, func(), error) {
+	// IMAP IDLE is handled by the existing IdleWatcher in main.go
+	return nil, nil, backend.ErrNotSupported
+}
+
+func (p *Provider) Close() error {
+	return nil
+}
+
+// Verify interface compliance at compile time.
+var _ backend.Provider = (*Provider)(nil)
+
+// Conversion helpers
+
+func toBackendEmails(emails []fetcher.Email) []backend.Email {
+	result := make([]backend.Email, len(emails))
+	for i, e := range emails {
+		result[i] = backend.Email{
+			UID:         e.UID,
+			From:        e.From,
+			To:          e.To,
+			Subject:     e.Subject,
+			Body:        e.Body,
+			Date:        e.Date,
+			IsRead:      e.IsRead,
+			MessageID:   e.MessageID,
+			References:  e.References,
+			Attachments: toBackendAttachments(e.Attachments),
+			AccountID:   e.AccountID,
+		}
+	}
+	return result
+}
+
+func toBackendAttachments(atts []fetcher.Attachment) []backend.Attachment {
+	result := make([]backend.Attachment, len(atts))
+	for i, a := range atts {
+		result[i] = backend.Attachment{
+			Filename:         a.Filename,
+			PartID:           a.PartID,
+			Data:             a.Data,
+			Encoding:         a.Encoding,
+			MIMEType:         a.MIMEType,
+			ContentID:        a.ContentID,
+			Inline:           a.Inline,
+			IsSMIMESignature: a.IsSMIMESignature,
+			SMIMEVerified:    a.SMIMEVerified,
+			IsSMIMEEncrypted: a.IsSMIMEEncrypted,
+		}
+	}
+	return result
+}
+
+func toBackendFolders(folders []fetcher.Folder) []backend.Folder {
+	result := make([]backend.Folder, len(folders))
+	for i, f := range folders {
+		result[i] = backend.Folder{
+			Name:       f.Name,
+			Delimiter:  f.Delimiter,
+			Attributes: f.Attributes,
+		}
+	}
+	return result
+}

backend/jmap/jmap.go 🔗

@@ -0,0 +1,586 @@
+// Package jmap implements the backend.Provider interface using the JMAP protocol
+// (RFC 8620 Core + RFC 8621 Mail).
+package jmap
+
+import (
+	"bytes"
+	"context"
+	"fmt"
+	"hash/fnv"
+	"io"
+	"strings"
+	"sync"
+	"time"
+
+	jmapclient "git.sr.ht/~rockorager/go-jmap"
+	"git.sr.ht/~rockorager/go-jmap/core/push"
+	"git.sr.ht/~rockorager/go-jmap/mail"
+	"git.sr.ht/~rockorager/go-jmap/mail/email"
+	"git.sr.ht/~rockorager/go-jmap/mail/emailsubmission"
+	"git.sr.ht/~rockorager/go-jmap/mail/mailbox"
+
+	"github.com/floatpane/matcha/backend"
+	"github.com/floatpane/matcha/config"
+)
+
+func init() {
+	backend.RegisterBackend("jmap", func(account *config.Account) (backend.Provider, error) {
+		return New(account)
+	})
+}
+
+// Provider implements backend.Provider using JMAP.
+type Provider struct {
+	account   *config.Account
+	client    *jmapclient.Client
+	accountID jmapclient.ID
+
+	mu         sync.Mutex
+	mailboxes  map[string]jmapclient.ID // name -> ID
+	roleToID   map[mailbox.Role]jmapclient.ID
+	idToJMAPID map[uint32]jmapclient.ID // UID hash -> JMAP ID
+}
+
+// New creates a new JMAP provider.
+func New(account *config.Account) (*Provider, error) {
+	if account.JMAPEndpoint == "" {
+		return nil, fmt.Errorf("JMAP endpoint URL not configured")
+	}
+
+	client := &jmapclient.Client{
+		SessionEndpoint: account.JMAPEndpoint,
+	}
+
+	if account.AuthMethod == "oauth2" {
+		client.WithAccessToken(account.Password)
+	} else {
+		client.WithBasicAuth(account.Email, account.Password)
+	}
+
+	if err := client.Authenticate(); err != nil {
+		return nil, fmt.Errorf("jmap auth: %w", err)
+	}
+
+	acctID := client.Session.PrimaryAccounts[mail.URI]
+	if acctID == "" {
+		return nil, fmt.Errorf("jmap: no mail account found in session")
+	}
+
+	p := &Provider{
+		account:    account,
+		client:     client,
+		accountID:  acctID,
+		mailboxes:  make(map[string]jmapclient.ID),
+		roleToID:   make(map[mailbox.Role]jmapclient.ID),
+		idToJMAPID: make(map[uint32]jmapclient.ID),
+	}
+
+	// Pre-fetch mailbox list
+	if err := p.refreshMailboxes(); err != nil {
+		return nil, fmt.Errorf("jmap mailboxes: %w", err)
+	}
+
+	return p, nil
+}
+
+func (p *Provider) refreshMailboxes() error {
+	req := &jmapclient.Request{}
+	req.Invoke(&mailbox.Get{
+		Account: p.accountID,
+	})
+
+	resp, err := p.client.Do(req)
+	if err != nil {
+		return err
+	}
+
+	p.mu.Lock()
+	defer p.mu.Unlock()
+
+	for _, inv := range resp.Responses {
+		if r, ok := inv.Args.(*mailbox.GetResponse); ok {
+			for _, mbox := range r.List {
+				p.mailboxes[mbox.Name] = mbox.ID
+				if mbox.Role != "" {
+					p.roleToID[mbox.Role] = mbox.ID
+				}
+			}
+		}
+	}
+	return nil
+}
+
+// resolveMailboxID maps a folder name to a JMAP mailbox ID.
+func (p *Provider) resolveMailboxID(folder string) (jmapclient.ID, error) {
+	p.mu.Lock()
+	defer p.mu.Unlock()
+
+	// Direct name match
+	if id, ok := p.mailboxes[folder]; ok {
+		return id, nil
+	}
+
+	// Role-based fallback for common folder names
+	nameToRole := map[string]mailbox.Role{
+		"INBOX":   mailbox.RoleInbox,
+		"Inbox":   mailbox.RoleInbox,
+		"Sent":    mailbox.RoleSent,
+		"Drafts":  mailbox.RoleDrafts,
+		"Trash":   mailbox.RoleTrash,
+		"Junk":    mailbox.RoleJunk,
+		"Spam":    mailbox.RoleJunk,
+		"Archive": mailbox.RoleArchive,
+	}
+	if role, ok := nameToRole[folder]; ok {
+		if id, ok := p.roleToID[role]; ok {
+			return id, nil
+		}
+	}
+
+	return "", fmt.Errorf("jmap: mailbox %q not found", folder)
+}
+
+func (p *Provider) FetchEmails(_ context.Context, folder string, limit, offset uint32) ([]backend.Email, error) {
+	mboxID, err := p.resolveMailboxID(folder)
+	if err != nil {
+		return nil, err
+	}
+
+	req := &jmapclient.Request{}
+
+	queryCallID := req.Invoke(&email.Query{
+		Account: p.accountID,
+		Filter:  &email.FilterCondition{InMailbox: mboxID},
+		Sort: []*email.SortComparator{
+			{Property: "receivedAt", IsAscending: false},
+		},
+		Position: int64(offset),
+		Limit:    uint64(limit),
+	})
+
+	req.Invoke(&email.Get{
+		Account: p.accountID,
+		ReferenceIDs: &jmapclient.ResultReference{
+			ResultOf: queryCallID,
+			Name:     "Email/query",
+			Path:     "/ids",
+		},
+		Properties: []string{
+			"id", "subject", "from", "to", "receivedAt",
+			"preview", "keywords", "mailboxIds", "hasAttachment",
+			"messageId",
+		},
+	})
+
+	resp, err := p.client.Do(req)
+	if err != nil {
+		return nil, fmt.Errorf("jmap fetch: %w", err)
+	}
+
+	var emails []backend.Email
+	for _, inv := range resp.Responses {
+		if r, ok := inv.Args.(*email.GetResponse); ok {
+			for _, eml := range r.List {
+				uid := jmapIDToUID(eml.ID)
+				p.mu.Lock()
+				p.idToJMAPID[uid] = eml.ID
+				p.mu.Unlock()
+
+				e := backend.Email{
+					UID:       uid,
+					Subject:   eml.Subject,
+					Date:      safeTime(eml.ReceivedAt),
+					IsRead:    eml.Keywords["$seen"],
+					AccountID: p.account.ID,
+				}
+				if len(eml.From) > 0 {
+					e.From = eml.From[0].String()
+				}
+				for _, addr := range eml.To {
+					e.To = append(e.To, addr.String())
+				}
+				if len(eml.MessageID) > 0 {
+					e.MessageID = eml.MessageID[0]
+				}
+				emails = append(emails, e)
+			}
+		}
+	}
+
+	return emails, nil
+}
+
+func (p *Provider) FetchEmailBody(_ context.Context, _ string, uid uint32) (string, []backend.Attachment, error) {
+	jmapID, err := p.lookupJMAPID(uid)
+	if err != nil {
+		return "", nil, err
+	}
+
+	req := &jmapclient.Request{}
+	req.Invoke(&email.Get{
+		Account: p.accountID,
+		IDs:     []jmapclient.ID{jmapID},
+		Properties: []string{
+			"id", "bodyValues", "htmlBody", "textBody", "attachments",
+			"bodyStructure",
+		},
+		BodyProperties:      []string{"partId", "blobId", "size", "type", "name", "disposition", "cid"},
+		FetchHTMLBodyValues: true,
+		FetchTextBodyValues: true,
+	})
+
+	resp, err := p.client.Do(req)
+	if err != nil {
+		return "", nil, fmt.Errorf("jmap body: %w", err)
+	}
+
+	for _, inv := range resp.Responses {
+		if r, ok := inv.Args.(*email.GetResponse); ok && len(r.List) > 0 {
+			eml := r.List[0]
+
+			// Get body text (prefer HTML)
+			var body string
+			for _, part := range eml.HTMLBody {
+				if val, ok := eml.BodyValues[part.PartID]; ok {
+					body = val.Value
+					break
+				}
+			}
+			if body == "" {
+				for _, part := range eml.TextBody {
+					if val, ok := eml.BodyValues[part.PartID]; ok {
+						body = val.Value
+						break
+					}
+				}
+			}
+
+			// Get attachments
+			var atts []backend.Attachment
+			for _, att := range eml.Attachments {
+				a := backend.Attachment{
+					Filename: att.Name,
+					PartID:   string(att.BlobID),
+					MIMEType: att.Type,
+					Inline:   att.Disposition == "inline",
+				}
+				if att.CID != "" {
+					a.ContentID = strings.Trim(att.CID, "<>")
+				}
+				atts = append(atts, a)
+			}
+
+			return body, atts, nil
+		}
+	}
+
+	return "", nil, fmt.Errorf("jmap: email not found")
+}
+
+func (p *Provider) FetchAttachment(_ context.Context, _ string, _ uint32, partID, _ string) ([]byte, error) {
+	// partID is the blobId for JMAP
+	blobID := jmapclient.ID(partID)
+	reader, err := p.client.Download(p.accountID, blobID)
+	if err != nil {
+		return nil, fmt.Errorf("jmap download: %w", err)
+	}
+	defer reader.Close()
+	return io.ReadAll(reader)
+}
+
+func (p *Provider) MarkAsRead(_ context.Context, _ string, uid uint32) error {
+	jmapID, err := p.lookupJMAPID(uid)
+	if err != nil {
+		return err
+	}
+
+	req := &jmapclient.Request{}
+	req.Invoke(&email.Set{
+		Account: p.accountID,
+		Update: map[jmapclient.ID]jmapclient.Patch{
+			jmapID: {"keywords/$seen": true},
+		},
+	})
+
+	_, err = p.client.Do(req)
+	return err
+}
+
+func (p *Provider) DeleteEmail(_ context.Context, _ string, uid uint32) error {
+	jmapID, err := p.lookupJMAPID(uid)
+	if err != nil {
+		return err
+	}
+
+	trashID, ok := p.roleToID[mailbox.RoleTrash]
+	if !ok {
+		// No trash, permanently delete
+		req := &jmapclient.Request{}
+		req.Invoke(&email.Set{
+			Account: p.accountID,
+			Destroy: []jmapclient.ID{jmapID},
+		})
+		_, err = p.client.Do(req)
+		return err
+	}
+
+	// Move to trash
+	req := &jmapclient.Request{}
+	req.Invoke(&email.Set{
+		Account: p.accountID,
+		Update: map[jmapclient.ID]jmapclient.Patch{
+			jmapID: {"mailboxIds": map[jmapclient.ID]bool{trashID: true}},
+		},
+	})
+	_, err = p.client.Do(req)
+	return err
+}
+
+func (p *Provider) ArchiveEmail(_ context.Context, _ string, uid uint32) error {
+	jmapID, err := p.lookupJMAPID(uid)
+	if err != nil {
+		return err
+	}
+
+	archiveID, ok := p.roleToID[mailbox.RoleArchive]
+	if !ok {
+		return fmt.Errorf("jmap: no archive mailbox found")
+	}
+
+	req := &jmapclient.Request{}
+	req.Invoke(&email.Set{
+		Account: p.accountID,
+		Update: map[jmapclient.ID]jmapclient.Patch{
+			jmapID: {"mailboxIds": map[jmapclient.ID]bool{archiveID: true}},
+		},
+	})
+	_, err = p.client.Do(req)
+	return err
+}
+
+func (p *Provider) MoveEmail(_ context.Context, uid uint32, _, dstFolder string) error {
+	jmapID, err := p.lookupJMAPID(uid)
+	if err != nil {
+		return err
+	}
+
+	dstID, err := p.resolveMailboxID(dstFolder)
+	if err != nil {
+		return err
+	}
+
+	req := &jmapclient.Request{}
+	req.Invoke(&email.Set{
+		Account: p.accountID,
+		Update: map[jmapclient.ID]jmapclient.Patch{
+			jmapID: {"mailboxIds": map[jmapclient.ID]bool{dstID: true}},
+		},
+	})
+	_, err = p.client.Do(req)
+	return err
+}
+
+func (p *Provider) SendEmail(_ context.Context, msg *backend.OutgoingEmail) error {
+	// Build the email as a draft first
+	toAddrs := make([]*mail.Address, len(msg.To))
+	for i, addr := range msg.To {
+		toAddrs[i] = &mail.Address{Email: addr}
+	}
+	ccAddrs := make([]*mail.Address, len(msg.Cc))
+	for i, addr := range msg.Cc {
+		ccAddrs[i] = &mail.Address{Email: addr}
+	}
+
+	// Build raw RFC5322 message and upload as blob
+	var buf bytes.Buffer
+	fmt.Fprintf(&buf, "From: %s\r\n", p.account.Email)
+	fmt.Fprintf(&buf, "To: %s\r\n", strings.Join(msg.To, ", "))
+	if len(msg.Cc) > 0 {
+		fmt.Fprintf(&buf, "Cc: %s\r\n", strings.Join(msg.Cc, ", "))
+	}
+	fmt.Fprintf(&buf, "Subject: %s\r\n", msg.Subject)
+	fmt.Fprintf(&buf, "Date: %s\r\n", time.Now().Format(time.RFC1123Z))
+	if msg.InReplyTo != "" {
+		fmt.Fprintf(&buf, "In-Reply-To: %s\r\n", msg.InReplyTo)
+	}
+	if len(msg.References) > 0 {
+		fmt.Fprintf(&buf, "References: %s\r\n", strings.Join(msg.References, " "))
+	}
+	fmt.Fprintf(&buf, "MIME-Version: 1.0\r\n")
+
+	body := msg.HTMLBody
+	ct := "text/html"
+	if body == "" {
+		body = msg.PlainBody
+		ct = "text/plain"
+	}
+	fmt.Fprintf(&buf, "Content-Type: %s; charset=utf-8\r\n", ct)
+	fmt.Fprintf(&buf, "\r\n%s", body)
+
+	// Upload the blob
+	uploadResp, err := p.client.Upload(p.accountID, &buf)
+	if err != nil {
+		return fmt.Errorf("jmap upload: %w", err)
+	}
+
+	// Create the email from the blob via Email/import would be ideal,
+	// but we can use Email/set create with the uploaded blob
+	draftsID := p.roleToID[mailbox.RoleDrafts]
+	if draftsID == "" {
+		// Use inbox as fallback
+		draftsID = p.roleToID[mailbox.RoleInbox]
+	}
+
+	req := &jmapclient.Request{}
+
+	// Import the uploaded blob as an email
+	createID := jmapclient.ID("draft")
+	req.Invoke(&email.Set{
+		Account: p.accountID,
+		Create: map[jmapclient.ID]*email.Email{
+			createID: {
+				BlobID:     uploadResp.ID,
+				MailboxIDs: map[jmapclient.ID]bool{draftsID: true},
+				Keywords:   map[string]bool{"$draft": true, "$seen": true},
+			},
+		},
+	})
+
+	// Build envelope recipients
+	var rcptTo []*emailsubmission.Address
+	for _, addr := range msg.To {
+		rcptTo = append(rcptTo, &emailsubmission.Address{Email: addr})
+	}
+	for _, addr := range msg.Cc {
+		rcptTo = append(rcptTo, &emailsubmission.Address{Email: addr})
+	}
+	for _, addr := range msg.Bcc {
+		rcptTo = append(rcptTo, &emailsubmission.Address{Email: addr})
+	}
+
+	sentID := p.roleToID[mailbox.RoleSent]
+
+	// Submit for sending
+	subReq := &emailsubmission.Set{
+		Account: p.accountID,
+		Create: map[jmapclient.ID]*emailsubmission.EmailSubmission{
+			"sub": {
+				EmailID: "#draft",
+				Envelope: &emailsubmission.Envelope{
+					MailFrom: &emailsubmission.Address{Email: p.account.Email},
+					RcptTo:   rcptTo,
+				},
+			},
+		},
+	}
+	if sentID != "" {
+		subReq.OnSuccessUpdateEmail = map[jmapclient.ID]jmapclient.Patch{
+			"#sub": {
+				"mailboxIds":      map[jmapclient.ID]bool{sentID: true},
+				"keywords/$draft": nil,
+			},
+		}
+	}
+	req.Invoke(subReq)
+
+	_, err = p.client.Do(req)
+	return err
+}
+
+func (p *Provider) FetchFolders(_ context.Context) ([]backend.Folder, error) {
+	if err := p.refreshMailboxes(); err != nil {
+		return nil, err
+	}
+
+	req := &jmapclient.Request{}
+	req.Invoke(&mailbox.Get{
+		Account: p.accountID,
+	})
+
+	resp, err := p.client.Do(req)
+	if err != nil {
+		return nil, err
+	}
+
+	var folders []backend.Folder
+	for _, inv := range resp.Responses {
+		if r, ok := inv.Args.(*mailbox.GetResponse); ok {
+			for _, mbox := range r.List {
+				folders = append(folders, backend.Folder{
+					Name:      mbox.Name,
+					Delimiter: "/",
+				})
+			}
+		}
+	}
+
+	return folders, nil
+}
+
+func (p *Provider) Watch(_ context.Context, _ string) (<-chan backend.NotifyEvent, func(), error) {
+	ch := make(chan backend.NotifyEvent, 16)
+
+	es := &push.EventSource{
+		Client: p.client,
+		Handler: func(change *jmapclient.StateChange) {
+			for _, typeState := range change.Changed {
+				for objType := range typeState {
+					if objType == "Email" || objType == "Mailbox" {
+						ch <- backend.NotifyEvent{
+							Type:      backend.NotifyNewEmail,
+							AccountID: p.account.ID,
+						}
+					}
+				}
+			}
+		},
+		Ping: 30,
+	}
+
+	go func() {
+		defer close(ch)
+		_ = es.Listen()
+	}()
+
+	cancel := func() {
+		es.Close()
+	}
+
+	return ch, cancel, nil
+}
+
+func (p *Provider) Close() error {
+	return nil
+}
+
+// Verify interface compliance at compile time.
+var _ backend.Provider = (*Provider)(nil)
+
+// lookupJMAPID resolves a uint32 UID hash back to the JMAP string ID.
+func (p *Provider) lookupJMAPID(uid uint32) (jmapclient.ID, error) {
+	p.mu.Lock()
+	defer p.mu.Unlock()
+	id, ok := p.idToJMAPID[uid]
+	if !ok {
+		return "", fmt.Errorf("jmap: no cached ID for UID %d", uid)
+	}
+	return id, nil
+}
+
+// jmapIDToUID converts a JMAP string ID to a uint32 hash for use as a UID.
+func jmapIDToUID(id jmapclient.ID) uint32 {
+	h := fnv.New32a()
+	h.Write([]byte(id))
+	v := h.Sum32()
+	if v == 0 {
+		v = 1
+	}
+	return v
+}
+
+func safeTime(t *time.Time) time.Time {
+	if t == nil {
+		return time.Time{}
+	}
+	return *t
+}

backend/pop3/pop3.go 🔗

@@ -0,0 +1,407 @@
+// Package pop3 implements the backend.Provider interface using POP3 for
+// reading email and SMTP for sending.
+//
+// POP3 is inherently limited compared to IMAP/JMAP:
+//   - Only supports a single "INBOX" folder
+//   - No support for flags (mark as read is a no-op)
+//   - No support for moving or archiving emails
+//   - No support for push notifications (IDLE)
+//   - Delete marks for deletion; executed on Quit()
+package pop3
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"mime"
+	"net/mail"
+	"strings"
+	"time"
+
+	"github.com/emersion/go-message"
+	gomail "github.com/emersion/go-message/mail"
+	pop3client "github.com/knadh/go-pop3"
+
+	"github.com/floatpane/matcha/backend"
+	"github.com/floatpane/matcha/config"
+	"github.com/floatpane/matcha/sender"
+)
+
+func init() {
+	backend.RegisterBackend("pop3", func(account *config.Account) (backend.Provider, error) {
+		return New(account)
+	})
+}
+
+// Provider implements backend.Provider using POP3+SMTP.
+type Provider struct {
+	account *config.Account
+	opt     pop3client.Opt
+}
+
+// New creates a new POP3 provider for the given account.
+func New(account *config.Account) (*Provider, error) {
+	server := account.GetPOP3Server()
+	port := account.GetPOP3Port()
+
+	if server == "" {
+		return nil, fmt.Errorf("POP3 server not configured")
+	}
+
+	opt := pop3client.Opt{
+		Host:          server,
+		Port:          port,
+		TLSEnabled:    true,
+		TLSSkipVerify: account.Insecure,
+	}
+
+	// Non-SSL ports use plain connection
+	if port == 110 {
+		opt.TLSEnabled = false
+	}
+
+	return &Provider{
+		account: account,
+		opt:     opt,
+	}, nil
+}
+
+// connect creates a new POP3 connection and authenticates.
+func (p *Provider) connect() (*pop3client.Conn, error) {
+	client := pop3client.New(p.opt)
+	conn, err := client.NewConn()
+	if err != nil {
+		return nil, fmt.Errorf("pop3 connect: %w", err)
+	}
+
+	if err := conn.Auth(p.account.Email, p.account.Password); err != nil {
+		conn.Quit()
+		return nil, fmt.Errorf("pop3 auth: %w", err)
+	}
+
+	return conn, nil
+}
+
+func (p *Provider) FetchEmails(_ context.Context, _ string, limit, offset uint32) ([]backend.Email, error) {
+	conn, err := p.connect()
+	if err != nil {
+		return nil, err
+	}
+	defer conn.Quit()
+
+	// Get message list with UIDs
+	msgs, err := conn.Uidl(0)
+	if err != nil {
+		// Fallback to LIST if UIDL not supported
+		msgs, err = conn.List(0)
+		if err != nil {
+			return nil, fmt.Errorf("pop3 list: %w", err)
+		}
+	}
+
+	if len(msgs) == 0 {
+		return []backend.Email{}, nil
+	}
+
+	// POP3 messages are 1-indexed. We want newest first (highest ID first).
+	start := len(msgs) - int(offset)
+	if start <= 0 {
+		return []backend.Email{}, nil
+	}
+
+	end := start - int(limit)
+	if end < 0 {
+		end = 0
+	}
+
+	var emails []backend.Email
+	for i := start; i > end; i-- {
+		msgInfo := msgs[i-1]
+
+		// Fetch headers only using TOP (0 lines of body)
+		entity, err := conn.Top(msgInfo.ID, 0)
+		if err != nil {
+			continue
+		}
+
+		email := entityToEmail(&entity.Header, msgInfo, p.account.ID)
+		emails = append(emails, email)
+	}
+
+	return emails, nil
+}
+
+func (p *Provider) FetchEmailBody(_ context.Context, _ string, uid uint32) (string, []backend.Attachment, error) {
+	conn, err := p.connect()
+	if err != nil {
+		return "", nil, err
+	}
+	defer conn.Quit()
+
+	msgID, err := p.findMessageByUID(conn, uid)
+	if err != nil {
+		return "", nil, err
+	}
+
+	raw, err := conn.RetrRaw(msgID)
+	if err != nil {
+		return "", nil, fmt.Errorf("pop3 retr: %w", err)
+	}
+
+	return parseMessageBody(raw)
+}
+
+func (p *Provider) FetchAttachment(_ context.Context, _ string, uid uint32, partID, _ string) ([]byte, error) {
+	conn, err := p.connect()
+	if err != nil {
+		return nil, err
+	}
+	defer conn.Quit()
+
+	msgID, err := p.findMessageByUID(conn, uid)
+	if err != nil {
+		return nil, err
+	}
+
+	raw, err := conn.RetrRaw(msgID)
+	if err != nil {
+		return nil, fmt.Errorf("pop3 retr: %w", err)
+	}
+
+	return findAttachmentData(raw, partID)
+}
+
+func (p *Provider) MarkAsRead(_ context.Context, _ string, _ uint32) error {
+	// POP3 has no concept of read/unread flags — this is a no-op
+	return nil
+}
+
+func (p *Provider) DeleteEmail(_ context.Context, _ string, uid uint32) error {
+	conn, err := p.connect()
+	if err != nil {
+		return err
+	}
+
+	msgID, err := p.findMessageByUID(conn, uid)
+	if err != nil {
+		conn.Quit()
+		return err
+	}
+
+	if err := conn.Dele(msgID); err != nil {
+		conn.Quit()
+		return fmt.Errorf("pop3 dele: %w", err)
+	}
+
+	// Quit commits the deletion
+	return conn.Quit()
+}
+
+func (p *Provider) ArchiveEmail(_ context.Context, _ string, _ uint32) error {
+	return backend.ErrNotSupported
+}
+
+func (p *Provider) MoveEmail(_ context.Context, _ uint32, _, _ string) error {
+	return backend.ErrNotSupported
+}
+
+func (p *Provider) SendEmail(_ context.Context, msg *backend.OutgoingEmail) error {
+	return sender.SendEmail(
+		p.account, msg.To, msg.Cc, msg.Bcc,
+		msg.Subject, msg.PlainBody, msg.HTMLBody,
+		msg.Images, msg.Attachments,
+		msg.InReplyTo, msg.References,
+		msg.SignSMIME, msg.EncryptSMIME,
+	)
+}
+
+func (p *Provider) FetchFolders(_ context.Context) ([]backend.Folder, error) {
+	return []backend.Folder{
+		{Name: "INBOX", Delimiter: "/"},
+	}, nil
+}
+
+func (p *Provider) Watch(_ context.Context, _ string) (<-chan backend.NotifyEvent, func(), error) {
+	return nil, nil, backend.ErrNotSupported
+}
+
+func (p *Provider) Close() error {
+	return nil
+}
+
+// Verify interface compliance at compile time.
+var _ backend.Provider = (*Provider)(nil)
+
+// findMessageByUID finds a POP3 message ID by matching the UID hash.
+func (p *Provider) findMessageByUID(conn *pop3client.Conn, uid uint32) (int, error) {
+	msgs, err := conn.Uidl(0)
+	if err != nil {
+		msgs, err = conn.List(0)
+		if err != nil {
+			return 0, fmt.Errorf("pop3 list: %w", err)
+		}
+		for _, m := range msgs {
+			if hashUID(fmt.Sprintf("%d", m.ID)) == uid {
+				return m.ID, nil
+			}
+		}
+		return 0, fmt.Errorf("pop3: message with UID %d not found", uid)
+	}
+
+	for _, m := range msgs {
+		if hashUID(m.UID) == uid {
+			return m.ID, nil
+		}
+	}
+	return 0, fmt.Errorf("pop3: message with UID %d not found", uid)
+}
+
+// hashUID converts a POP3 UIDL string to a uint32 hash.
+func hashUID(uidl string) uint32 {
+	var hash uint32
+	for _, c := range uidl {
+		hash = hash*31 + uint32(c)
+	}
+	if hash == 0 {
+		hash = 1
+	}
+	return hash
+}
+
+// entityToEmail converts message headers to a backend.Email.
+func entityToEmail(header *message.Header, msgInfo pop3client.MessageID, accountID string) backend.Email {
+	from := header.Get("From")
+	subject := header.Get("Subject")
+	dateStr := header.Get("Date")
+	messageID := header.Get("Message-ID")
+
+	var to []string
+	if toHeader := header.Get("To"); toHeader != "" {
+		for _, addr := range strings.Split(toHeader, ",") {
+			to = append(to, strings.TrimSpace(addr))
+		}
+	}
+
+	var date time.Time
+	if dateStr != "" {
+		if parsed, err := mail.ParseDate(dateStr); err == nil {
+			date = parsed
+		}
+	}
+
+	// Decode MIME-encoded headers
+	dec := new(mime.WordDecoder)
+	if decoded, err := dec.DecodeHeader(subject); err == nil {
+		subject = decoded
+	}
+	if decoded, err := dec.DecodeHeader(from); err == nil {
+		from = decoded
+	}
+
+	uidStr := msgInfo.UID
+	if uidStr == "" {
+		uidStr = fmt.Sprintf("%d", msgInfo.ID)
+	}
+
+	return backend.Email{
+		UID:       hashUID(uidStr),
+		From:      from,
+		To:        to,
+		Subject:   subject,
+		Date:      date,
+		IsRead:    false,
+		MessageID: messageID,
+		AccountID: accountID,
+	}
+}
+
+// parseMessageBody extracts the body text and attachments from a raw message.
+func parseMessageBody(r io.Reader) (string, []backend.Attachment, error) {
+	mr, err := gomail.CreateReader(r)
+	if err != nil {
+		// Not a multipart message — read body directly
+		body, err := io.ReadAll(r)
+		if err != nil {
+			return "", nil, err
+		}
+		return string(body), nil, nil
+	}
+
+	var bodyText string
+	var htmlBody string
+	var attachments []backend.Attachment
+	partIdx := 0
+
+	for {
+		part, err := mr.NextPart()
+		if err == io.EOF {
+			break
+		}
+		if err != nil {
+			break
+		}
+		partIdx++
+
+		contentType, _, _ := mime.ParseMediaType(part.Header.Get("Content-Type"))
+		disposition, dParams, _ := mime.ParseMediaType(part.Header.Get("Content-Disposition"))
+
+		data, readErr := io.ReadAll(part.Body)
+		if readErr != nil {
+			continue
+		}
+
+		if disposition == "attachment" || (disposition == "inline" && !strings.HasPrefix(contentType, "text/")) {
+			filename := dParams["filename"]
+			if filename == "" {
+				_, cp, _ := mime.ParseMediaType(part.Header.Get("Content-Type"))
+				filename = cp["name"]
+			}
+			att := backend.Attachment{
+				Filename: filename,
+				PartID:   fmt.Sprintf("%d", partIdx),
+				Data:     data,
+				MIMEType: contentType,
+				Inline:   disposition == "inline",
+			}
+			if cid := part.Header.Get("Content-ID"); cid != "" {
+				att.ContentID = strings.Trim(cid, "<>")
+			}
+			attachments = append(attachments, att)
+		} else if contentType == "text/html" {
+			htmlBody = string(data)
+		} else if contentType == "text/plain" && bodyText == "" {
+			bodyText = string(data)
+		}
+	}
+
+	if htmlBody != "" {
+		return htmlBody, attachments, nil
+	}
+	return bodyText, attachments, nil
+}
+
+// findAttachmentData walks a raw message to find attachment data by partID.
+func findAttachmentData(r io.Reader, targetPartID string) ([]byte, error) {
+	mr, err := gomail.CreateReader(r)
+	if err != nil {
+		return nil, fmt.Errorf("not a multipart message")
+	}
+
+	partIdx := 0
+	for {
+		part, err := mr.NextPart()
+		if err == io.EOF {
+			break
+		}
+		if err != nil {
+			break
+		}
+		partIdx++
+
+		if fmt.Sprintf("%d", partIdx) == targetPartID {
+			return io.ReadAll(part.Body)
+		}
+	}
+
+	return nil, fmt.Errorf("pop3: attachment part %s not found", targetPartID)
+}

config/config.go 🔗

@@ -36,6 +36,12 @@ type Account struct {
 
 	// OAuth2 settings
 	AuthMethod string `json:"auth_method,omitempty"` // "password" (default) or "oauth2"
+
+	// Multi-protocol settings
+	Protocol     string `json:"protocol,omitempty"`      // "imap" (default), "jmap", or "pop3"
+	JMAPEndpoint string `json:"jmap_endpoint,omitempty"` // JMAP session URL (for protocol=jmap)
+	POP3Server   string `json:"pop3_server,omitempty"`   // POP3 server hostname (for protocol=pop3)
+	POP3Port     int    `json:"pop3_port,omitempty"`     // POP3 server port (for protocol=pop3)
 }
 
 // MailingList represents a named group of email addresses.
@@ -112,6 +118,22 @@ func (a *Account) GetSMTPPort() int {
 	}
 }
 
+// GetPOP3Server returns the POP3 server address for the account.
+func (a *Account) GetPOP3Server() string {
+	if a.POP3Server != "" {
+		return a.POP3Server
+	}
+	return ""
+}
+
+// GetPOP3Port returns the POP3 port for the account.
+func (a *Account) GetPOP3Port() int {
+	if a.POP3Port != 0 {
+		return a.POP3Port
+	}
+	return 995 // Default POP3 SSL port
+}
+
 // GetConfigDir returns the path to the configuration directory (exported).
 func GetConfigDir() (string, error) {
 	return configDir()
@@ -191,6 +213,10 @@ func LoadConfig() (*Config, error) {
 		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"`
 	}
 	type diskConfig struct {
 		Accounts             []rawAccount  `json:"accounts"`
@@ -247,6 +273,10 @@ func LoadConfig() (*Config, error) {
 			SMIMEKey:           rawAcc.SMIMEKey,
 			SMIMESignByDefault: rawAcc.SMIMESignByDefault,
 			AuthMethod:         rawAcc.AuthMethod,
+			Protocol:           rawAcc.Protocol,
+			JMAPEndpoint:       rawAcc.JMAPEndpoint,
+			POP3Server:         rawAcc.POP3Server,
+			POP3Port:           rawAcc.POP3Port,
 		}
 
 		if rawAcc.Password != "" {

go.mod 🔗

@@ -19,6 +19,7 @@ require (
 )
 
 require (
+	git.sr.ht/~rockorager/go-jmap v0.5.3 // indirect
 	github.com/andybalholm/cascadia v1.3.3 // indirect
 	github.com/atotto/clipboard v0.1.4 // indirect
 	github.com/charmbracelet/colorprofile v0.4.2 // indirect
@@ -32,6 +33,8 @@ require (
 	github.com/danieljoos/wincred v1.2.3 // indirect
 	github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
 	github.com/godbus/dbus/v5 v5.2.2 // indirect
+	github.com/golang/protobuf v1.5.2 // indirect
+	github.com/knadh/go-pop3 v1.0.2 // indirect
 	github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
 	github.com/mattn/go-runewidth v0.0.20 // indirect
 	github.com/muesli/cancelreader v0.2.2 // indirect
@@ -39,5 +42,8 @@ require (
 	github.com/sahilm/fuzzy v0.1.1 // indirect
 	github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
 	golang.org/x/net v0.52.0 // indirect
+	golang.org/x/oauth2 v0.4.0 // indirect
 	golang.org/x/sync v0.20.0 // indirect
+	google.golang.org/appengine v1.6.7 // indirect
+	google.golang.org/protobuf v1.28.0 // indirect
 )

go.sum 🔗

@@ -4,6 +4,8 @@ charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0=
 charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
 charm.land/lipgloss/v2 v2.0.2 h1:xFolbF8JdpNkM2cEPTfXEcW1p6NRzOWTSamRfYEw8cs=
 charm.land/lipgloss/v2 v2.0.2/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM=
+git.sr.ht/~rockorager/go-jmap v0.5.3 h1:PjnobX0ySPHKG5TiUqLM6PlM3ngYHlLJeWLOeorZ7IY=
+git.sr.ht/~rockorager/go-jmap v0.5.3/go.mod h1:aOTCtwpZSINpDDSOkLGpHU0Kbbm5lcSDMcobX3ZtOjY=
 github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
 github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
 github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo=
@@ -47,9 +49,16 @@ github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTe
 github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U=
 github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
 github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
+github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
+github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
+github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
+github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/knadh/go-pop3 v1.0.2 h1:gbdtwzEYedLVos/vpebM2d73NTyZxEgjgRJ4S77HlzM=
+github.com/knadh/go-pop3 v1.0.2/go.mod h1:3gKw2jmrEa1lYLVtP1yEoo6bkkJ4XHDySPy8xaSjG0s=
 github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
 github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
@@ -92,6 +101,7 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
 golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
 golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
 golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
+golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
 golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
@@ -103,6 +113,8 @@ golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
 golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
 golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
 golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
+golang.org/x/oauth2 v0.4.0 h1:NF0gk8LVPg1Ml7SSbGyySuoxdsXitj7TvgvuRxIMc/M=
+golang.org/x/oauth2 v0.4.0/go.mod h1:RznEsdpjGAINPTOF0UH/t+xJ75L18YO3Ho6Pyn+uRec=
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -135,6 +147,7 @@ golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
 golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
 golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
@@ -153,5 +166,12 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
 golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
 golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
+google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
+google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
+google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
+google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw=
+google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

main.go 🔗

@@ -20,6 +20,10 @@ import (
 	"time"
 
 	tea "charm.land/bubbletea/v2"
+	"github.com/floatpane/matcha/backend"
+	_ "github.com/floatpane/matcha/backend/imap"
+	_ "github.com/floatpane/matcha/backend/jmap"
+	_ "github.com/floatpane/matcha/backend/pop3"
 	"github.com/floatpane/matcha/clib"
 	"github.com/floatpane/matcha/config"
 	"github.com/floatpane/matcha/fetcher"
@@ -77,6 +81,8 @@ type mainModel struct {
 	// IMAP IDLE
 	idleWatcher *fetcher.IdleWatcher
 	idleUpdates chan fetcher.IdleUpdate
+	// Multi-protocol backend providers (keyed by account ID)
+	providers map[string]backend.Provider
 }
 
 func newInitialModel(cfg *config.Config) *mainModel {
@@ -86,6 +92,7 @@ func newInitialModel(cfg *config.Config) *mainModel {
 		folderEmails: make(map[string][]fetcher.Email),
 		idleUpdates:  idleUpdates,
 		idleWatcher:  fetcher.NewIdleWatcher(idleUpdates),
+		providers:    make(map[string]backend.Provider),
 	}
 
 	if cfg == nil || !cfg.HasAccounts() {
@@ -101,6 +108,32 @@ func newInitialModel(cfg *config.Config) *mainModel {
 	return initialModel
 }
 
+// ensureProviders creates backend providers for all configured accounts.
+func (m *mainModel) ensureProviders() {
+	if m.config == nil {
+		return
+	}
+	for _, acct := range m.config.Accounts {
+		if _, ok := m.providers[acct.ID]; ok {
+			continue
+		}
+		p, err := backend.New(&acct)
+		if err != nil {
+			log.Printf("backend: failed to create provider for %s: %v", acct.Email, err)
+			continue
+		}
+		m.providers[acct.ID] = p
+	}
+}
+
+// getProvider returns the backend provider for the given account.
+func (m *mainModel) getProvider(acct *config.Account) backend.Provider {
+	if acct == nil {
+		return nil
+	}
+	return m.providers[acct.ID]
+}
+
 func (m *mainModel) Init() tea.Cmd {
 	return tea.Batch(m.current.Init(), checkForUpdatesCmd())
 }
@@ -221,9 +254,13 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				ServiceProvider: msg.Provider,
 				FetchEmail:      fetchEmails[0],
 				AuthMethod:      msg.AuthMethod,
+				Protocol:        msg.Protocol,
+				JMAPEndpoint:    msg.JMAPEndpoint,
+				POP3Server:      msg.POP3Server,
+				POP3Port:        msg.POP3Port,
 			}
 
-			if msg.Provider == "custom" {
+			if msg.Provider == "custom" || msg.Protocol == "pop3" {
 				account.IMAPServer = msg.IMAPServer
 				account.IMAPPort = msg.IMAPPort
 				account.SMTPServer = msg.SMTPServer
@@ -259,9 +296,13 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 					ServiceProvider: msg.Provider,
 					FetchEmail:      fe,
 					AuthMethod:      msg.AuthMethod,
+					Protocol:        msg.Protocol,
+					JMAPEndpoint:    msg.JMAPEndpoint,
+					POP3Server:      msg.POP3Server,
+					POP3Port:        msg.POP3Port,
 				}
 
-				if msg.Provider == "custom" {
+				if msg.Provider == "custom" || msg.Protocol == "pop3" {
 					account.IMAPServer = msg.IMAPServer
 					account.IMAPPort = msg.IMAPPort
 					account.SMTPServer = msg.SMTPServer
@@ -308,6 +349,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			m.current = tui.NewLogin(hideTips)
 			return m, m.current.Init()
 		}
+		m.ensureProviders()
 		// Load cached folders from all accounts, merge unique names
 		seen := make(map[string]bool)
 		var cachedFolders []string
@@ -705,7 +747,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			hideTips = m.config.HideTips
 		}
 		login := tui.NewLogin(hideTips)
-		login.SetEditMode(msg.AccountID, msg.Provider, msg.Name, msg.Email, msg.FetchEmail, msg.IMAPServer, msg.IMAPPort, msg.SMTPServer, msg.SMTPPort)
+		login.SetEditMode(msg.AccountID, msg.Protocol, msg.Provider, msg.Name, msg.Email, msg.FetchEmail, msg.IMAPServer, msg.IMAPPort, msg.SMTPServer, msg.SMTPPort, msg.JMAPEndpoint, msg.POP3Server, msg.POP3Port)
 		m.current = login
 		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 		return m, m.current.Init()

tui/login.go 🔗

@@ -22,7 +22,8 @@ type Login struct {
 }
 
 const (
-	inputProvider = iota
+	inputProtocol = iota // "imap", "jmap", or "pop3"
+	inputProvider        // "gmail", "icloud", or "custom"
 	inputName
 	inputEmail
 	inputFetchEmail
@@ -32,6 +33,9 @@ const (
 	inputIMAPPort
 	inputSMTPServer
 	inputSMTPPort
+	inputJMAPEndpoint // JMAP session URL
+	inputPOP3Server
+	inputPOP3Port
 	inputCount
 )
 
@@ -50,9 +54,12 @@ func NewLogin(hideTips bool) *Login {
 		t.SetStyles(tiStyles)
 
 		switch i {
+		case inputProtocol:
+			t.Placeholder = "Protocol (imap, jmap, or pop3)"
+			t.Focus()
+			t.Prompt = "🌐 > "
 		case inputProvider:
 			t.Placeholder = "Provider (gmail, icloud, or custom)"
-			t.Focus()
 			t.Prompt = "🏢 > "
 		case inputName:
 			t.Placeholder = "Display Name"
@@ -82,6 +89,15 @@ func NewLogin(hideTips bool) *Login {
 		case inputSMTPPort:
 			t.Placeholder = "SMTP Port (default: 587)"
 			t.Prompt = "🔢 > "
+		case inputJMAPEndpoint:
+			t.Placeholder = "JMAP Session URL (e.g., https://api.fastmail.com/jmap/session)"
+			t.Prompt = "🔗 > "
+		case inputPOP3Server:
+			t.Placeholder = "POP3 Server (e.g., pop.example.com)"
+			t.Prompt = "📥 > "
+		case inputPOP3Port:
+			t.Placeholder = "POP3 Port (default: 995)"
+			t.Prompt = "🔢 > "
 		}
 		m.inputs[i] = t
 	}
@@ -94,6 +110,49 @@ func (m *Login) Init() tea.Cmd {
 	return textinput.Blink
 }
 
+// protocol returns the currently selected protocol (defaults to "imap").
+func (m *Login) protocol() string {
+	p := m.inputs[inputProtocol].Value()
+	if p == "" {
+		return "imap"
+	}
+	return p
+}
+
+// visibleFields returns the ordered list of input indices the user should see
+// for the current protocol/provider/auth combination.
+func (m *Login) visibleFields() []int {
+	proto := m.protocol()
+	provider := m.inputs[inputProvider].Value()
+	isGmail := provider == "gmail"
+
+	fields := []int{inputProtocol}
+
+	switch proto {
+	case "jmap":
+		// JMAP: no provider selector, just endpoint + common fields
+		fields = append(fields, inputName, inputEmail, inputFetchEmail, inputPassword, inputJMAPEndpoint)
+	case "pop3":
+		// POP3: custom server fields + SMTP for sending
+		fields = append(fields, inputName, inputEmail, inputFetchEmail, inputPassword,
+			inputPOP3Server, inputPOP3Port, inputSMTPServer, inputSMTPPort)
+	default:
+		// IMAP (default): existing flow
+		fields = append(fields, inputProvider, inputName, inputEmail, inputFetchEmail)
+		if isGmail {
+			fields = append(fields, inputAuthMethod)
+		}
+		if !m.useOAuth2 {
+			fields = append(fields, inputPassword)
+		}
+		if m.showCustom {
+			fields = append(fields, inputIMAPServer, inputIMAPPort, inputSMTPServer, inputSMTPPort)
+		}
+	}
+
+	return fields
+}
+
 // Update handles messages for the login model.
 func (m *Login) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	switch msg := msg.(type) {
@@ -110,115 +169,42 @@ func (m *Login) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			return m, func() tea.Msg { return GoToChoiceMenuMsg{} }
 
 		case "enter":
-			// Check if provider is "custom" to show/hide custom fields
-			provider := m.inputs[inputProvider].Value()
-			m.showCustom = provider == "custom"
-			m.useOAuth2 = m.inputs[inputAuthMethod].Value() == "oauth2"
-
-			lastFieldIndex := inputPassword
-			if m.useOAuth2 {
-				// OAuth2: last field before submit is the auth method field
-				lastFieldIndex = inputAuthMethod
-			}
-			if m.showCustom {
-				lastFieldIndex = inputSMTPPort
-			}
-
-			if m.focusIndex == lastFieldIndex {
-				// Submit the form
-				imapPort := 993
-				smtpPort := 587
-				if m.inputs[inputIMAPPort].Value() != "" {
-					if p, err := strconv.Atoi(m.inputs[inputIMAPPort].Value()); err == nil {
-						imapPort = p
-					}
-				}
-				if m.inputs[inputSMTPPort].Value() != "" {
-					if p, err := strconv.Atoi(m.inputs[inputSMTPPort].Value()); err == nil {
-						smtpPort = p
-					}
-				}
-
-				authMethod := "password"
-				if m.useOAuth2 {
-					authMethod = "oauth2"
-				}
+			m.updateFlags()
+			visible := m.visibleFields()
+			lastField := visible[len(visible)-1]
 
-				return m, func() tea.Msg {
-					return Credentials{
-						Provider:   m.inputs[inputProvider].Value(),
-						Name:       m.inputs[inputName].Value(),
-						Host:       m.inputs[inputEmail].Value(),
-						FetchEmail: m.inputs[inputFetchEmail].Value(),
-						Password:   m.inputs[inputPassword].Value(),
-						IMAPServer: m.inputs[inputIMAPServer].Value(),
-						IMAPPort:   imapPort,
-						SMTPServer: m.inputs[inputSMTPServer].Value(),
-						SMTPPort:   smtpPort,
-						AuthMethod: authMethod,
-					}
-				}
+			if m.focusIndex == lastField {
+				return m, m.submitForm()
 			}
 			fallthrough
 
 		case "tab", "shift+tab", "up", "down":
 			s := msg.String()
-
-			// Check provider to update showCustom and useOAuth2
-			provider := m.inputs[inputProvider].Value()
-			m.showCustom = provider == "custom"
-			m.useOAuth2 = m.inputs[inputAuthMethod].Value() == "oauth2"
-
-			maxIndex := inputPassword
-			if m.useOAuth2 {
-				maxIndex = inputAuthMethod
-			}
-			if m.showCustom {
-				maxIndex = inputSMTPPort
+			m.updateFlags()
+			visible := m.visibleFields()
+
+			// Find current position in visible fields
+			curPos := 0
+			for i, f := range visible {
+				if f == m.focusIndex {
+					curPos = i
+					break
+				}
 			}
 
 			if s == "up" || s == "shift+tab" {
-				m.focusIndex--
+				curPos--
 			} else {
-				m.focusIndex++
+				curPos++
 			}
 
-			if m.focusIndex > maxIndex {
-				m.focusIndex = 0
-			} else if m.focusIndex < 0 {
-				m.focusIndex = maxIndex
+			if curPos >= len(visible) {
+				curPos = 0
+			} else if curPos < 0 {
+				curPos = len(visible) - 1
 			}
 
-			// Skip password field when using OAuth2
-			if m.useOAuth2 && m.focusIndex == inputPassword {
-				if s == "up" || s == "shift+tab" {
-					m.focusIndex = inputAuthMethod
-				} else {
-					m.focusIndex = 0
-				}
-			}
-
-			// Skip auth method field when not Gmail (only Gmail supports OAuth2)
-			if provider != "gmail" && m.focusIndex == inputAuthMethod {
-				if s == "up" || s == "shift+tab" {
-					m.focusIndex = inputFetchEmail
-				} else {
-					m.focusIndex = inputPassword
-				}
-			}
-
-			// Skip custom fields if not showing them
-			if !m.showCustom && m.focusIndex > inputPassword {
-				if s == "up" || s == "shift+tab" {
-					if m.useOAuth2 {
-						m.focusIndex = inputAuthMethod
-					} else {
-						m.focusIndex = inputPassword
-					}
-				} else {
-					m.focusIndex = 0
-				}
-			}
+			m.focusIndex = visible[curPos]
 
 			cmds := make([]tea.Cmd, len(m.inputs))
 			for i := 0; i < len(m.inputs); i++ {
@@ -238,12 +224,64 @@ func (m *Login) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		m.inputs[i], cmds[i] = m.inputs[i].Update(msg)
 	}
 
-	// Check if provider changed
+	m.updateFlags()
+
+	return m, tea.Batch(cmds...)
+}
+
+// updateFlags recalculates showCustom and useOAuth2 from current inputs.
+func (m *Login) updateFlags() {
 	provider := m.inputs[inputProvider].Value()
 	m.showCustom = provider == "custom"
 	m.useOAuth2 = m.inputs[inputAuthMethod].Value() == "oauth2"
+}
 
-	return m, tea.Batch(cmds...)
+// submitForm builds and returns a Credentials message from the current inputs.
+func (m *Login) submitForm() func() tea.Msg {
+	imapPort := 993
+	smtpPort := 587
+	pop3Port := 995
+	if m.inputs[inputIMAPPort].Value() != "" {
+		if p, err := strconv.Atoi(m.inputs[inputIMAPPort].Value()); err == nil {
+			imapPort = p
+		}
+	}
+	if m.inputs[inputSMTPPort].Value() != "" {
+		if p, err := strconv.Atoi(m.inputs[inputSMTPPort].Value()); err == nil {
+			smtpPort = p
+		}
+	}
+	if m.inputs[inputPOP3Port].Value() != "" {
+		if p, err := strconv.Atoi(m.inputs[inputPOP3Port].Value()); err == nil {
+			pop3Port = p
+		}
+	}
+
+	authMethod := "password"
+	if m.useOAuth2 {
+		authMethod = "oauth2"
+	}
+
+	proto := m.protocol()
+
+	return func() tea.Msg {
+		return Credentials{
+			Protocol:     proto,
+			Provider:     m.inputs[inputProvider].Value(),
+			Name:         m.inputs[inputName].Value(),
+			Host:         m.inputs[inputEmail].Value(),
+			FetchEmail:   m.inputs[inputFetchEmail].Value(),
+			Password:     m.inputs[inputPassword].Value(),
+			IMAPServer:   m.inputs[inputIMAPServer].Value(),
+			IMAPPort:     imapPort,
+			SMTPServer:   m.inputs[inputSMTPServer].Value(),
+			SMTPPort:     smtpPort,
+			AuthMethod:   authMethod,
+			JMAPEndpoint: m.inputs[inputJMAPEndpoint].Value(),
+			POP3Server:   m.inputs[inputPOP3Server].Value(),
+			POP3Port:     pop3Port,
+		}
+	}
 }
 
 // View renders the login form.
@@ -253,13 +291,12 @@ func (m *Login) View() tea.View {
 		title = "Edit Account"
 	}
 
-	customHint := ""
-	if m.inputs[inputProvider].Value() == "custom" || m.showCustom {
-		customHint = "\n" + accountEmailStyle.Render("Custom provider selected - configure server settings below")
-	}
+	proto := m.protocol()
 
 	tip := ""
 	switch m.focusIndex {
+	case inputProtocol:
+		tip = "Choose the protocol: imap (default), jmap, or pop3."
 	case inputProvider:
 		tip = "Enter your email provider (e.g., gmail, icloud) or 'custom'."
 	case inputName:
@@ -280,41 +317,78 @@ func (m *Login) View() tea.View {
 		tip = "The server address for sending emails."
 	case inputSMTPPort:
 		tip = "The port for the SMTP server (usually 587 for TLS)."
+	case inputJMAPEndpoint:
+		tip = "The JMAP session resource URL (e.g., https://api.fastmail.com/jmap/session)."
+	case inputPOP3Server:
+		tip = "The POP3 server address for receiving emails."
+	case inputPOP3Port:
+		tip = "The port for the POP3 server (usually 995 for SSL)."
 	}
 
-	isGmail := m.inputs[inputProvider].Value() == "gmail"
-
 	views := []string{
 		titleStyle.Render(title),
 		"Enter your email account credentials.",
-		customHint,
-		m.inputs[inputProvider].View(),
-		m.inputs[inputName].View(),
-		m.inputs[inputEmail].View(),
-		m.inputs[inputFetchEmail].View(),
-	}
-
-	// Show auth method selector for Gmail
-	if isGmail {
-		views = append(views, m.inputs[inputAuthMethod].View())
+		"",
+		m.inputs[inputProtocol].View(),
 	}
 
-	// Hide password field when using OAuth2
-	if !m.useOAuth2 {
-		views = append(views, m.inputs[inputPassword].View())
-	} else {
-		views = append(views, accountEmailStyle.Render("OAuth2 selected — browser authorization will open after submit"))
-	}
-
-	if m.showCustom {
+	switch proto {
+	case "jmap":
+		views = append(views,
+			m.inputs[inputName].View(),
+			m.inputs[inputEmail].View(),
+			m.inputs[inputFetchEmail].View(),
+			m.inputs[inputPassword].View(),
+			"",
+			listHeader.Render("JMAP Settings:"),
+			m.inputs[inputJMAPEndpoint].View(),
+		)
+	case "pop3":
 		views = append(views,
+			m.inputs[inputName].View(),
+			m.inputs[inputEmail].View(),
+			m.inputs[inputFetchEmail].View(),
+			m.inputs[inputPassword].View(),
+			"",
+			listHeader.Render("POP3 Server Settings:"),
+			m.inputs[inputPOP3Server].View(),
+			m.inputs[inputPOP3Port].View(),
 			"",
-			listHeader.Render("Custom Server Settings:"),
-			m.inputs[inputIMAPServer].View(),
-			m.inputs[inputIMAPPort].View(),
+			listHeader.Render("SMTP Settings (for sending):"),
 			m.inputs[inputSMTPServer].View(),
 			m.inputs[inputSMTPPort].View(),
 		)
+	default:
+		// IMAP flow
+		isGmail := m.inputs[inputProvider].Value() == "gmail"
+		views = append(views,
+			m.inputs[inputProvider].View(),
+			m.inputs[inputName].View(),
+			m.inputs[inputEmail].View(),
+			m.inputs[inputFetchEmail].View(),
+		)
+
+		if isGmail {
+			views = append(views, m.inputs[inputAuthMethod].View())
+		}
+
+		if !m.useOAuth2 {
+			views = append(views, m.inputs[inputPassword].View())
+		} else {
+			views = append(views, accountEmailStyle.Render("OAuth2 selected — browser authorization will open after submit"))
+		}
+
+		if m.showCustom {
+			customHint := accountEmailStyle.Render("Custom provider selected - configure server settings below")
+			views = append(views,
+				"",
+				customHint,
+				m.inputs[inputIMAPServer].View(),
+				m.inputs[inputIMAPPort].View(),
+				m.inputs[inputSMTPServer].View(),
+				m.inputs[inputSMTPPort].View(),
+			)
+		}
 	}
 
 	views = append(views, "")
@@ -327,9 +401,14 @@ func (m *Login) View() tea.View {
 }
 
 // SetEditMode sets the login form to edit an existing account.
-func (m *Login) SetEditMode(accountID, provider, name, email, fetchEmail, imapServer string, imapPort int, smtpServer string, smtpPort int) {
+func (m *Login) SetEditMode(accountID, protocol, provider, name, email, fetchEmail, imapServer string, imapPort int, smtpServer string, smtpPort int, jmapEndpoint, pop3Server string, pop3Port int) {
 	m.isEditMode = true
 	m.accountID = accountID
+
+	if protocol == "" {
+		protocol = "imap"
+	}
+	m.inputs[inputProtocol].SetValue(protocol)
 	m.inputs[inputProvider].SetValue(provider)
 	m.inputs[inputName].SetValue(name)
 	m.inputs[inputEmail].SetValue(email)
@@ -346,6 +425,23 @@ func (m *Login) SetEditMode(accountID, provider, name, email, fetchEmail, imapSe
 			m.inputs[inputSMTPPort].SetValue(strconv.Itoa(smtpPort))
 		}
 	}
+
+	if jmapEndpoint != "" {
+		m.inputs[inputJMAPEndpoint].SetValue(jmapEndpoint)
+	}
+	if pop3Server != "" {
+		m.inputs[inputPOP3Server].SetValue(pop3Server)
+	}
+	if pop3Port != 0 {
+		m.inputs[inputPOP3Port].SetValue(strconv.Itoa(pop3Port))
+	}
+	// Also set SMTP for POP3
+	if protocol == "pop3" {
+		m.inputs[inputSMTPServer].SetValue(smtpServer)
+		if smtpPort != 0 {
+			m.inputs[inputSMTPPort].SetValue(strconv.Itoa(smtpPort))
+		}
+	}
 }
 
 // GetAccountID returns the account ID being edited (if in edit mode).

tui/messages.go 🔗

@@ -38,16 +38,20 @@ type SendEmailMsg struct {
 }
 
 type Credentials struct {
-	Provider   string
-	Name       string
-	Host       string // Host (this was the previous "Email Address" field in the UI)
-	FetchEmail string // Single email address to fetch messages for. If empty, code should default this to Host when creating the account.
-	Password   string
-	IMAPServer string
-	IMAPPort   int
-	SMTPServer string
-	SMTPPort   int
-	AuthMethod string // "password" or "oauth2"
+	Provider     string
+	Name         string
+	Host         string // Host (this was the previous "Email Address" field in the UI)
+	FetchEmail   string // Single email address to fetch messages for. If empty, code should default this to Host when creating the account.
+	Password     string
+	IMAPServer   string
+	IMAPPort     int
+	SMTPServer   string
+	SMTPPort     int
+	AuthMethod   string // "password" or "oauth2"
+	Protocol     string // "imap" (default), "jmap", or "pop3"
+	JMAPEndpoint string // JMAP session URL
+	POP3Server   string // POP3 server hostname
+	POP3Port     int    // POP3 server port
 }
 
 // StartOAuth2Msg is sent when the user requests OAuth2 authorization for a Gmail account.
@@ -198,15 +202,19 @@ type GoToAddMailingListMsg struct{}
 
 // GoToEditAccountMsg signals navigation to edit an existing account.
 type GoToEditAccountMsg struct {
-	AccountID  string
-	Provider   string
-	Name       string
-	Email      string
-	FetchEmail string
-	IMAPServer string
-	IMAPPort   int
-	SMTPServer string
-	SMTPPort   int
+	AccountID    string
+	Provider     string
+	Name         string
+	Email        string
+	FetchEmail   string
+	IMAPServer   string
+	IMAPPort     int
+	SMTPServer   string
+	SMTPPort     int
+	Protocol     string
+	JMAPEndpoint string
+	POP3Server   string
+	POP3Port     int
 }
 
 // GoToEditMailingListMsg signals navigation to edit an existing mailing list.

tui/settings.go 🔗

@@ -214,15 +214,19 @@ func (m *Settings) updateAccounts(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
 			acc := m.cfg.Accounts[m.cursor]
 			return m, func() tea.Msg {
 				return GoToEditAccountMsg{
-					AccountID:  acc.ID,
-					Provider:   acc.ServiceProvider,
-					Name:       acc.Name,
-					Email:      acc.Email,
-					FetchEmail: acc.FetchEmail,
-					IMAPServer: acc.IMAPServer,
-					IMAPPort:   acc.IMAPPort,
-					SMTPServer: acc.SMTPServer,
-					SMTPPort:   acc.SMTPPort,
+					AccountID:    acc.ID,
+					Provider:     acc.ServiceProvider,
+					Name:         acc.Name,
+					Email:        acc.Email,
+					FetchEmail:   acc.FetchEmail,
+					IMAPServer:   acc.IMAPServer,
+					IMAPPort:     acc.IMAPPort,
+					SMTPServer:   acc.SMTPServer,
+					SMTPPort:     acc.SMTPPort,
+					Protocol:     acc.Protocol,
+					JMAPEndpoint: acc.JMAPEndpoint,
+					POP3Server:   acc.POP3Server,
+					POP3Port:     acc.POP3Port,
 				}
 			}
 		}