diff --git a/backend/backend.go b/backend/backend.go new file mode 100644 index 0000000..ee1599e --- /dev/null +++ b/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 +} diff --git a/backend/factory.go b/backend/factory.go new file mode 100644 index 0000000..0d0ade0 --- /dev/null +++ b/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) +} diff --git a/backend/imap/imap.go b/backend/imap/imap.go new file mode 100644 index 0000000..32e4d09 --- /dev/null +++ b/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 +} diff --git a/backend/jmap/jmap.go b/backend/jmap/jmap.go new file mode 100644 index 0000000..0e34d72 --- /dev/null +++ b/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 +} diff --git a/backend/pop3/pop3.go b/backend/pop3/pop3.go new file mode 100644 index 0000000..053ddb6 --- /dev/null +++ b/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) +} diff --git a/config/config.go b/config/config.go index a31a08e..9971335 100644 --- a/config/config.go +++ b/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 != "" { diff --git a/go.mod b/go.mod index 4db4805..19346c0 100644 --- a/go.mod +++ b/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 ) diff --git a/go.sum b/go.sum index 37bdf83..9479be3 100644 --- a/go.sum +++ b/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= diff --git a/main.go b/main.go index a5b9ab7..04498d2 100644 --- a/main.go +++ b/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() diff --git a/tui/login.go b/tui/login.go index d6c6bc8..de69980 100644 --- a/tui/login.go +++ b/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). diff --git a/tui/messages.go b/tui/messages.go index 9091688..fd9ad3c 100644 --- a/tui/messages.go +++ b/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. diff --git a/tui/settings.go b/tui/settings.go index 18766b7..de674e6 100644 --- a/tui/settings.go +++ b/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, } } }