jmap.go

  1// Package jmap implements the backend.Provider interface using the JMAP protocol
  2// (RFC 8620 Core + RFC 8621 Mail).
  3package jmap
  4
  5import (
  6	"bytes"
  7	"context"
  8	"fmt"
  9	"hash/fnv"
 10	"io"
 11	"strings"
 12	"sync"
 13	"time"
 14
 15	jmapclient "git.sr.ht/~rockorager/go-jmap"
 16	"git.sr.ht/~rockorager/go-jmap/core/push"
 17	"git.sr.ht/~rockorager/go-jmap/mail"
 18	"git.sr.ht/~rockorager/go-jmap/mail/email"
 19	"git.sr.ht/~rockorager/go-jmap/mail/emailsubmission"
 20	"git.sr.ht/~rockorager/go-jmap/mail/mailbox"
 21
 22	"github.com/floatpane/matcha/backend"
 23	"github.com/floatpane/matcha/config"
 24)
 25
 26func init() {
 27	backend.RegisterBackend("jmap", func(account *config.Account) (backend.Provider, error) {
 28		return New(account)
 29	})
 30}
 31
 32// Provider implements backend.Provider using JMAP.
 33type Provider struct {
 34	account   *config.Account
 35	client    *jmapclient.Client
 36	accountID jmapclient.ID
 37
 38	mu         sync.Mutex
 39	mailboxes  map[string]jmapclient.ID // name -> ID
 40	roleToID   map[mailbox.Role]jmapclient.ID
 41	idToJMAPID map[uint32]jmapclient.ID // UID hash -> JMAP ID
 42}
 43
 44// New creates a new JMAP provider.
 45func New(account *config.Account) (*Provider, error) {
 46	if account.JMAPEndpoint == "" {
 47		return nil, fmt.Errorf("JMAP endpoint URL not configured")
 48	}
 49
 50	client := &jmapclient.Client{
 51		SessionEndpoint: account.JMAPEndpoint,
 52	}
 53
 54	if account.AuthMethod == "oauth2" {
 55		client.WithAccessToken(account.Password)
 56	} else {
 57		client.WithBasicAuth(account.Email, account.Password)
 58	}
 59
 60	if err := client.Authenticate(); err != nil {
 61		return nil, fmt.Errorf("jmap auth: %w", err)
 62	}
 63
 64	acctID := client.Session.PrimaryAccounts[mail.URI]
 65	if acctID == "" {
 66		return nil, fmt.Errorf("jmap: no mail account found in session")
 67	}
 68
 69	p := &Provider{
 70		account:    account,
 71		client:     client,
 72		accountID:  acctID,
 73		mailboxes:  make(map[string]jmapclient.ID),
 74		roleToID:   make(map[mailbox.Role]jmapclient.ID),
 75		idToJMAPID: make(map[uint32]jmapclient.ID),
 76	}
 77
 78	// Pre-fetch mailbox list
 79	if err := p.refreshMailboxes(); err != nil {
 80		return nil, fmt.Errorf("jmap mailboxes: %w", err)
 81	}
 82
 83	return p, nil
 84}
 85
 86func (p *Provider) refreshMailboxes() error {
 87	req := &jmapclient.Request{}
 88	req.Invoke(&mailbox.Get{
 89		Account: p.accountID,
 90	})
 91
 92	resp, err := p.client.Do(req)
 93	if err != nil {
 94		return err
 95	}
 96
 97	p.mu.Lock()
 98	defer p.mu.Unlock()
 99
100	for _, inv := range resp.Responses {
101		if r, ok := inv.Args.(*mailbox.GetResponse); ok {
102			for _, mbox := range r.List {
103				p.mailboxes[mbox.Name] = mbox.ID
104				if mbox.Role != "" {
105					p.roleToID[mbox.Role] = mbox.ID
106				}
107			}
108		}
109	}
110	return nil
111}
112
113// resolveMailboxID maps a folder name to a JMAP mailbox ID.
114func (p *Provider) resolveMailboxID(folder string) (jmapclient.ID, error) {
115	p.mu.Lock()
116	defer p.mu.Unlock()
117
118	// Direct name match
119	if id, ok := p.mailboxes[folder]; ok {
120		return id, nil
121	}
122
123	// Role-based fallback for common folder names
124	nameToRole := map[string]mailbox.Role{
125		"INBOX":   mailbox.RoleInbox,
126		"Inbox":   mailbox.RoleInbox,
127		"Sent":    mailbox.RoleSent,
128		"Drafts":  mailbox.RoleDrafts,
129		"Trash":   mailbox.RoleTrash,
130		"Junk":    mailbox.RoleJunk,
131		"Spam":    mailbox.RoleJunk,
132		"Archive": mailbox.RoleArchive,
133	}
134	if role, ok := nameToRole[folder]; ok {
135		if id, ok := p.roleToID[role]; ok {
136			return id, nil
137		}
138	}
139
140	return "", fmt.Errorf("jmap: mailbox %q not found", folder)
141}
142
143func (p *Provider) FetchEmails(_ context.Context, folder string, limit, offset uint32) ([]backend.Email, error) {
144	mboxID, err := p.resolveMailboxID(folder)
145	if err != nil {
146		return nil, err
147	}
148
149	req := &jmapclient.Request{}
150
151	queryCallID := req.Invoke(&email.Query{
152		Account: p.accountID,
153		Filter:  &email.FilterCondition{InMailbox: mboxID},
154		Sort: []*email.SortComparator{
155			{Property: "receivedAt", IsAscending: false},
156		},
157		Position: int64(offset),
158		Limit:    uint64(limit),
159	})
160
161	req.Invoke(&email.Get{
162		Account: p.accountID,
163		ReferenceIDs: &jmapclient.ResultReference{
164			ResultOf: queryCallID,
165			Name:     "Email/query",
166			Path:     "/ids",
167		},
168		Properties: []string{"id", "subject", "from", "to", "replyTo", "receivedAt", "preview", "keywords", "mailboxIds", "hasAttachment", "messageId"},
169	})
170
171	resp, err := p.client.Do(req)
172	if err != nil {
173		return nil, fmt.Errorf("jmap fetch: %w", err)
174	}
175
176	var emails []backend.Email
177	for _, inv := range resp.Responses {
178		if r, ok := inv.Args.(*email.GetResponse); ok {
179			for _, eml := range r.List {
180				uid := jmapIDToUID(eml.ID)
181				p.mu.Lock()
182				p.idToJMAPID[uid] = eml.ID
183				p.mu.Unlock()
184
185				e := jmapEmailToBackend(eml, uid, p.account.ID)
186				emails = append(emails, e)
187			}
188		}
189	}
190
191	return emails, nil
192}
193
194func (p *Provider) Search(_ context.Context, folder string, query backend.SearchQuery) ([]backend.Email, error) {
195	mboxID, err := p.resolveMailboxID(folder)
196	if err != nil {
197		return nil, err
198	}
199
200	req := &jmapclient.Request{}
201	queryCallID := req.Invoke(&email.Query{
202		Account: p.accountID,
203		Filter:  buildSearchFilter(mboxID, query),
204		Sort: []*email.SortComparator{
205			{Property: "receivedAt", IsAscending: false},
206		},
207		Limit: uint64(searchLimit(query)),
208	})
209
210	req.Invoke(&email.Get{
211		Account: p.accountID,
212		ReferenceIDs: &jmapclient.ResultReference{
213			ResultOf: queryCallID,
214			Name:     "Email/query",
215			Path:     "/ids",
216		},
217		Properties: []string{
218			"id", "subject", "from", "to", "replyTo", "receivedAt",
219			"preview", "keywords", "mailboxIds", "hasAttachment",
220			"messageId",
221		},
222	})
223
224	resp, err := p.client.Do(req)
225	if err != nil {
226		return nil, fmt.Errorf("jmap search: %w", err)
227	}
228
229	var emails []backend.Email
230	for _, inv := range resp.Responses {
231		if r, ok := inv.Args.(*email.GetResponse); ok {
232			for _, eml := range r.List {
233				uid := jmapIDToUID(eml.ID)
234				p.mu.Lock()
235				p.idToJMAPID[uid] = eml.ID
236				p.mu.Unlock()
237
238				emails = append(emails, jmapEmailToBackend(eml, uid, p.account.ID))
239			}
240		}
241	}
242
243	return emails, nil
244}
245
246func buildSearchFilter(mboxID jmapclient.ID, query backend.SearchQuery) *email.FilterCondition {
247	f := &email.FilterCondition{InMailbox: mboxID}
248	if query.From != "" {
249		f.From = query.From
250	}
251	if query.To != "" {
252		f.To = query.To
253	}
254	if query.Subject != "" {
255		f.Subject = query.Subject
256	}
257	if query.Body != "" {
258		f.Body = query.Body
259	}
260	if !query.Since.IsZero() {
261		f.After = &query.Since
262	}
263	if !query.Before.IsZero() {
264		f.Before = &query.Before
265	}
266	if query.LargerThan > 0 {
267		f.MinSize = uint64(query.LargerThan)
268	}
269	return f
270}
271
272func searchLimit(query backend.SearchQuery) uint32 {
273	if query.Limit > 0 {
274		return query.Limit
275	}
276	return 100
277}
278
279func (p *Provider) FetchEmailBody(_ context.Context, _ string, uid uint32) (string, []backend.Attachment, error) {
280	jmapID, err := p.lookupJMAPID(uid)
281	if err != nil {
282		return "", nil, err
283	}
284
285	req := &jmapclient.Request{}
286	req.Invoke(&email.Get{
287		Account: p.accountID,
288		IDs:     []jmapclient.ID{jmapID},
289		Properties: []string{
290			"id", "bodyValues", "htmlBody", "textBody", "attachments",
291			"bodyStructure",
292		},
293		BodyProperties:      []string{"partId", "blobId", "size", "type", "name", "disposition", "cid"},
294		FetchHTMLBodyValues: true,
295		FetchTextBodyValues: true,
296	})
297
298	resp, err := p.client.Do(req)
299	if err != nil {
300		return "", nil, fmt.Errorf("jmap body: %w", err)
301	}
302
303	for _, inv := range resp.Responses {
304		if r, ok := inv.Args.(*email.GetResponse); ok && len(r.List) > 0 {
305			eml := r.List[0]
306
307			// Get body text (prefer HTML)
308			var body string
309			for _, part := range eml.HTMLBody {
310				if val, ok := eml.BodyValues[part.PartID]; ok {
311					body = val.Value
312					break
313				}
314			}
315			if body == "" {
316				for _, part := range eml.TextBody {
317					if val, ok := eml.BodyValues[part.PartID]; ok {
318						body = val.Value
319						break
320					}
321				}
322			}
323
324			// Get attachments
325			var atts []backend.Attachment
326			for _, att := range eml.Attachments {
327				a := backend.Attachment{
328					Filename: att.Name,
329					PartID:   string(att.BlobID),
330					MIMEType: att.Type,
331					Inline:   att.Disposition == "inline",
332				}
333				if att.CID != "" {
334					a.ContentID = strings.Trim(att.CID, "<>")
335				}
336				atts = append(atts, a)
337			}
338
339			return body, atts, nil
340		}
341	}
342
343	return "", nil, fmt.Errorf("jmap: email not found")
344}
345
346func (p *Provider) FetchAttachment(_ context.Context, _ string, _ uint32, partID, _ string) ([]byte, error) {
347	// partID is the blobId for JMAP
348	blobID := jmapclient.ID(partID)
349	reader, err := p.client.Download(p.accountID, blobID)
350	if err != nil {
351		return nil, fmt.Errorf("jmap download: %w", err)
352	}
353	defer reader.Close()
354	return io.ReadAll(reader)
355}
356
357func (p *Provider) MarkAsRead(_ context.Context, _ string, uid uint32) error {
358	jmapID, err := p.lookupJMAPID(uid)
359	if err != nil {
360		return err
361	}
362
363	req := &jmapclient.Request{}
364	req.Invoke(&email.Set{
365		Account: p.accountID,
366		Update: map[jmapclient.ID]jmapclient.Patch{
367			jmapID: {"keywords/$seen": true},
368		},
369	})
370
371	_, err = p.client.Do(req)
372	return err
373}
374
375func (p *Provider) DeleteEmail(_ context.Context, _ string, uid uint32) error {
376	jmapID, err := p.lookupJMAPID(uid)
377	if err != nil {
378		return err
379	}
380
381	trashID, ok := p.roleToID[mailbox.RoleTrash]
382	if !ok {
383		// No trash, permanently delete
384		req := &jmapclient.Request{}
385		req.Invoke(&email.Set{
386			Account: p.accountID,
387			Destroy: []jmapclient.ID{jmapID},
388		})
389		_, err = p.client.Do(req)
390		return err
391	}
392
393	// Move to trash
394	req := &jmapclient.Request{}
395	req.Invoke(&email.Set{
396		Account: p.accountID,
397		Update: map[jmapclient.ID]jmapclient.Patch{
398			jmapID: {"mailboxIds": map[jmapclient.ID]bool{trashID: true}},
399		},
400	})
401	_, err = p.client.Do(req)
402	return err
403}
404
405func (p *Provider) ArchiveEmail(_ context.Context, _ string, uid uint32) error {
406	jmapID, err := p.lookupJMAPID(uid)
407	if err != nil {
408		return err
409	}
410
411	archiveID, ok := p.roleToID[mailbox.RoleArchive]
412	if !ok {
413		return fmt.Errorf("jmap: no archive mailbox found")
414	}
415
416	req := &jmapclient.Request{}
417	req.Invoke(&email.Set{
418		Account: p.accountID,
419		Update: map[jmapclient.ID]jmapclient.Patch{
420			jmapID: {"mailboxIds": map[jmapclient.ID]bool{archiveID: true}},
421		},
422	})
423	_, err = p.client.Do(req)
424	return err
425}
426
427func (p *Provider) MoveEmail(_ context.Context, uid uint32, _, dstFolder string) error {
428	jmapID, err := p.lookupJMAPID(uid)
429	if err != nil {
430		return err
431	}
432
433	dstID, err := p.resolveMailboxID(dstFolder)
434	if err != nil {
435		return err
436	}
437
438	req := &jmapclient.Request{}
439	req.Invoke(&email.Set{
440		Account: p.accountID,
441		Update: map[jmapclient.ID]jmapclient.Patch{
442			jmapID: {"mailboxIds": map[jmapclient.ID]bool{dstID: true}},
443		},
444	})
445	_, err = p.client.Do(req)
446	return err
447}
448
449func (p *Provider) DeleteEmails(ctx context.Context, folder string, uids []uint32) error {
450	// JMAP can handle batch operations - loop through for now
451	for _, uid := range uids {
452		if err := p.DeleteEmail(ctx, folder, uid); err != nil {
453			return err
454		}
455	}
456	return nil
457}
458
459func (p *Provider) ArchiveEmails(ctx context.Context, folder string, uids []uint32) error {
460	// JMAP can handle batch operations - loop through for now
461	for _, uid := range uids {
462		if err := p.ArchiveEmail(ctx, folder, uid); err != nil {
463			return err
464		}
465	}
466	return nil
467}
468
469func (p *Provider) MoveEmails(ctx context.Context, uids []uint32, srcFolder, dstFolder string) error {
470	// JMAP can handle batch operations - loop through for now
471	for _, uid := range uids {
472		if err := p.MoveEmail(ctx, uid, srcFolder, dstFolder); err != nil {
473			return err
474		}
475	}
476	return nil
477}
478
479func (p *Provider) SendEmail(_ context.Context, msg *backend.OutgoingEmail) error {
480	// Build the email as a draft first
481	toAddrs := make([]*mail.Address, len(msg.To))
482	for i, addr := range msg.To {
483		toAddrs[i] = &mail.Address{Email: addr}
484	}
485	ccAddrs := make([]*mail.Address, len(msg.Cc))
486	for i, addr := range msg.Cc {
487		ccAddrs[i] = &mail.Address{Email: addr}
488	}
489
490	// Build raw RFC5322 message and upload as blob
491	var buf bytes.Buffer
492	fmt.Fprintf(&buf, "From: %s\r\n", p.account.FormatFromHeader())
493	fmt.Fprintf(&buf, "To: %s\r\n", strings.Join(msg.To, ", "))
494	if len(msg.Cc) > 0 {
495		fmt.Fprintf(&buf, "Cc: %s\r\n", strings.Join(msg.Cc, ", "))
496	}
497	fmt.Fprintf(&buf, "Subject: %s\r\n", msg.Subject)
498	fmt.Fprintf(&buf, "Date: %s\r\n", time.Now().Format(time.RFC1123Z))
499	if msg.InReplyTo != "" {
500		fmt.Fprintf(&buf, "In-Reply-To: %s\r\n", msg.InReplyTo)
501	}
502	if len(msg.References) > 0 {
503		fmt.Fprintf(&buf, "References: %s\r\n", strings.Join(msg.References, " "))
504	}
505	fmt.Fprintf(&buf, "MIME-Version: 1.0\r\n")
506
507	body := msg.HTMLBody
508	ct := "text/html"
509	if body == "" {
510		body = msg.PlainBody
511		ct = "text/plain"
512	}
513	fmt.Fprintf(&buf, "Content-Type: %s; charset=utf-8\r\n", ct)
514	fmt.Fprintf(&buf, "\r\n%s", body)
515
516	// Upload the blob
517	uploadResp, err := p.client.Upload(p.accountID, &buf)
518	if err != nil {
519		return fmt.Errorf("jmap upload: %w", err)
520	}
521
522	// Create the email from the blob via Email/import would be ideal,
523	// but we can use Email/set create with the uploaded blob
524	draftsID := p.roleToID[mailbox.RoleDrafts]
525	if draftsID == "" {
526		// Use inbox as fallback
527		draftsID = p.roleToID[mailbox.RoleInbox]
528	}
529
530	req := &jmapclient.Request{}
531
532	// Import the uploaded blob as an email
533	createID := jmapclient.ID("draft")
534	req.Invoke(&email.Set{
535		Account: p.accountID,
536		Create: map[jmapclient.ID]*email.Email{
537			createID: {
538				BlobID:     uploadResp.ID,
539				MailboxIDs: map[jmapclient.ID]bool{draftsID: true},
540				Keywords:   map[string]bool{"$draft": true, "$seen": true},
541			},
542		},
543	})
544
545	// Build envelope recipients
546	var rcptTo []*emailsubmission.Address
547	for _, addr := range msg.To {
548		rcptTo = append(rcptTo, &emailsubmission.Address{Email: addr})
549	}
550	for _, addr := range msg.Cc {
551		rcptTo = append(rcptTo, &emailsubmission.Address{Email: addr})
552	}
553	for _, addr := range msg.Bcc {
554		rcptTo = append(rcptTo, &emailsubmission.Address{Email: addr})
555	}
556
557	sentID := p.roleToID[mailbox.RoleSent]
558
559	// Submit for sending
560	subReq := &emailsubmission.Set{
561		Account: p.accountID,
562		Create: map[jmapclient.ID]*emailsubmission.EmailSubmission{
563			"sub": {
564				EmailID: "#draft",
565				Envelope: &emailsubmission.Envelope{
566					MailFrom: &emailsubmission.Address{Email: p.account.Email},
567					RcptTo:   rcptTo,
568				},
569			},
570		},
571	}
572	if sentID != "" {
573		subReq.OnSuccessUpdateEmail = map[jmapclient.ID]jmapclient.Patch{
574			"#sub": {
575				"mailboxIds":      map[jmapclient.ID]bool{sentID: true},
576				"keywords/$draft": nil,
577			},
578		}
579	}
580	req.Invoke(subReq)
581
582	_, err = p.client.Do(req)
583	return err
584}
585
586func (p *Provider) FetchFolders(_ context.Context) ([]backend.Folder, error) {
587	if err := p.refreshMailboxes(); err != nil {
588		return nil, err
589	}
590
591	req := &jmapclient.Request{}
592	req.Invoke(&mailbox.Get{
593		Account: p.accountID,
594	})
595
596	resp, err := p.client.Do(req)
597	if err != nil {
598		return nil, err
599	}
600
601	var folders []backend.Folder
602	for _, inv := range resp.Responses {
603		if r, ok := inv.Args.(*mailbox.GetResponse); ok {
604			for _, mbox := range r.List {
605				folders = append(folders, backend.Folder{
606					Name:      mbox.Name,
607					Delimiter: "/",
608				})
609			}
610		}
611	}
612
613	return folders, nil
614}
615
616func (p *Provider) Watch(_ context.Context, _ string) (<-chan backend.NotifyEvent, func(), error) {
617	ch := make(chan backend.NotifyEvent, 16)
618
619	es := &push.EventSource{
620		Client: p.client,
621		Handler: func(change *jmapclient.StateChange) {
622			for _, typeState := range change.Changed {
623				for objType := range typeState {
624					if objType == "Email" || objType == "Mailbox" {
625						ch <- backend.NotifyEvent{
626							Type:      backend.NotifyNewEmail,
627							AccountID: p.account.ID,
628						}
629					}
630				}
631			}
632		},
633		Ping: 30,
634	}
635
636	go func() {
637		defer close(ch)
638		_ = es.Listen()
639	}()
640
641	cancel := func() {
642		es.Close()
643	}
644
645	return ch, cancel, nil
646}
647
648func (p *Provider) Close() error {
649	return nil
650}
651
652// Verify interface compliance at compile time.
653var _ backend.Provider = (*Provider)(nil)
654
655// lookupJMAPID resolves a uint32 UID hash back to the JMAP string ID.
656func (p *Provider) lookupJMAPID(uid uint32) (jmapclient.ID, error) {
657	p.mu.Lock()
658	defer p.mu.Unlock()
659	id, ok := p.idToJMAPID[uid]
660	if !ok {
661		return "", fmt.Errorf("jmap: no cached ID for UID %d", uid)
662	}
663	return id, nil
664}
665
666// jmapIDToUID converts a JMAP string ID to a uint32 hash for use as a UID.
667func jmapIDToUID(id jmapclient.ID) uint32 {
668	h := fnv.New32a()
669	h.Write([]byte(id))
670	v := h.Sum32()
671	if v == 0 {
672		v = 1
673	}
674	return v
675}
676
677// jmapEmailToBackend converts a JMAP email to a backend.Email.
678func jmapEmailToBackend(eml *email.Email, uid uint32, accountID string) backend.Email {
679	e := backend.Email{
680		UID:       uid,
681		Subject:   eml.Subject,
682		Date:      safeTime(eml.ReceivedAt),
683		IsRead:    eml.Keywords["$seen"],
684		AccountID: accountID,
685	}
686	if len(eml.From) > 0 {
687		e.From = eml.From[0].String()
688	}
689	for _, addr := range eml.To {
690		e.To = append(e.To, addr.Email)
691	}
692	for _, addr := range eml.ReplyTo {
693		e.ReplyTo = append(e.ReplyTo, addr.Email)
694	}
695	if len(eml.MessageID) > 0 {
696		e.MessageID = eml.MessageID[0]
697	}
698	return e
699}
700
701func safeTime(t *time.Time) time.Time {
702	if t == nil {
703		return time.Time{}
704	}
705	return *t
706}