Detailed changes
@@ -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
+}
@@ -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)
+}
@@ -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
+}
@@ -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
+}
@@ -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)
+}
@@ -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 != "" {
@@ -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
)
@@ -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=
@@ -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()
@@ -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).
@@ -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.
@@ -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,
}
}
}