Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 additions & 0 deletions backend/backend.go
Original file line number Diff line number Diff line change
@@ -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
}
31 changes: 31 additions & 0 deletions backend/factory.go
Original file line number Diff line number Diff line change
@@ -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)
}
147 changes: 147 additions & 0 deletions backend/imap/imap.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading