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{
169			"id", "subject", "from", "to", "replyTo", "receivedAt",
170			"preview", "keywords", "mailboxIds", "hasAttachment",
171			"messageId", "inReplyTo", "references",
172		},
173	})
174
175	resp, err := p.client.Do(req)
176	if err != nil {
177		return nil, fmt.Errorf("jmap fetch: %w", err)
178	}
179
180	var emails []backend.Email
181	for _, inv := range resp.Responses {
182		if r, ok := inv.Args.(*email.GetResponse); ok {
183			for _, eml := range r.List {
184				uid := jmapIDToUID(eml.ID)
185				p.mu.Lock()
186				p.idToJMAPID[uid] = eml.ID
187				p.mu.Unlock()
188
189				e := jmapEmailToBackend(eml, uid, p.account.ID)
190				emails = append(emails, e)
191			}
192		}
193	}
194
195	return emails, nil
196}
197
198func (p *Provider) Search(_ context.Context, folder string, query backend.SearchQuery) ([]backend.Email, error) {
199	mboxID, err := p.resolveMailboxID(folder)
200	if err != nil {
201		return nil, err
202	}
203
204	req := &jmapclient.Request{}
205	queryCallID := req.Invoke(&email.Query{
206		Account: p.accountID,
207		Filter:  buildSearchFilter(mboxID, query),
208		Sort: []*email.SortComparator{
209			{Property: "receivedAt", IsAscending: false},
210		},
211		Limit: uint64(searchLimit(query)),
212	})
213
214	req.Invoke(&email.Get{
215		Account: p.accountID,
216		ReferenceIDs: &jmapclient.ResultReference{
217			ResultOf: queryCallID,
218			Name:     "Email/query",
219			Path:     "/ids",
220		},
221		Properties: []string{
222			"id", "subject", "from", "to", "replyTo", "receivedAt",
223			"preview", "keywords", "mailboxIds", "hasAttachment",
224			"messageId",
225		},
226	})
227
228	resp, err := p.client.Do(req)
229	if err != nil {
230		return nil, fmt.Errorf("jmap search: %w", err)
231	}
232
233	var emails []backend.Email
234	for _, inv := range resp.Responses {
235		if r, ok := inv.Args.(*email.GetResponse); ok {
236			for _, eml := range r.List {
237				uid := jmapIDToUID(eml.ID)
238				p.mu.Lock()
239				p.idToJMAPID[uid] = eml.ID
240				p.mu.Unlock()
241
242				emails = append(emails, jmapEmailToBackend(eml, uid, p.account.ID))
243			}
244		}
245	}
246
247	return emails, nil
248}
249
250func buildSearchFilter(mboxID jmapclient.ID, query backend.SearchQuery) *email.FilterCondition {
251	f := &email.FilterCondition{InMailbox: mboxID}
252	if query.From != "" {
253		f.From = query.From
254	}
255	if query.To != "" {
256		f.To = query.To
257	}
258	if query.Subject != "" {
259		f.Subject = query.Subject
260	}
261	if query.Body != "" {
262		f.Body = query.Body
263	}
264	if !query.Since.IsZero() {
265		f.After = &query.Since
266	}
267	if !query.Before.IsZero() {
268		f.Before = &query.Before
269	}
270	if query.LargerThan > 0 {
271		f.MinSize = uint64(query.LargerThan)
272	}
273	return f
274}
275
276func searchLimit(query backend.SearchQuery) uint32 {
277	if query.Limit > 0 {
278		return query.Limit
279	}
280	return 100
281}
282
283func (p *Provider) FetchEmailBody(_ context.Context, _ string, uid uint32) (string, string, []backend.Attachment, error) {
284	jmapID, err := p.lookupJMAPID(uid)
285	if err != nil {
286		return "", "", nil, err
287	}
288
289	req := &jmapclient.Request{}
290	req.Invoke(&email.Get{
291		Account: p.accountID,
292		IDs:     []jmapclient.ID{jmapID},
293		Properties: []string{
294			"id", "bodyValues", "htmlBody", "textBody", "attachments",
295			"bodyStructure",
296		},
297		BodyProperties:      []string{"partId", "blobId", "size", "type", "name", "disposition", "cid"},
298		FetchHTMLBodyValues: true,
299		FetchTextBodyValues: true,
300	})
301
302	resp, err := p.client.Do(req)
303	if err != nil {
304		return "", "", nil, fmt.Errorf("jmap body: %w", err)
305	}
306
307	for _, inv := range resp.Responses {
308		if r, ok := inv.Args.(*email.GetResponse); ok && len(r.List) > 0 {
309			eml := r.List[0]
310
311			// Get body text (prefer HTML)
312			var body, mimeType string
313			for _, part := range eml.HTMLBody {
314				if val, ok := eml.BodyValues[part.PartID]; ok {
315					body = val.Value
316					mimeType = "text/html"
317					break
318				}
319			}
320			if body == "" {
321				for _, part := range eml.TextBody {
322					if val, ok := eml.BodyValues[part.PartID]; ok {
323						body = val.Value
324						mimeType = "text/plain"
325						break
326					}
327				}
328			}
329
330			// Get attachments
331			var atts []backend.Attachment
332			for _, att := range eml.Attachments {
333				a := backend.Attachment{
334					Filename: att.Name,
335					PartID:   string(att.BlobID),
336					MIMEType: att.Type,
337					Inline:   att.Disposition == "inline",
338				}
339				if att.CID != "" {
340					a.ContentID = strings.Trim(att.CID, "<>")
341				}
342				atts = append(atts, a)
343			}
344
345			return body, mimeType, atts, nil
346		}
347	}
348
349	return "", "", nil, fmt.Errorf("jmap: email not found")
350}
351
352func (p *Provider) FetchAttachment(_ context.Context, _ string, _ uint32, partID, _ string) ([]byte, error) {
353	// partID is the blobId for JMAP
354	blobID := jmapclient.ID(partID)
355	reader, err := p.client.Download(p.accountID, blobID)
356	if err != nil {
357		return nil, fmt.Errorf("jmap download: %w", err)
358	}
359	defer reader.Close()
360	return io.ReadAll(reader)
361}
362
363func (p *Provider) MarkAsRead(_ context.Context, _ string, uid uint32) error {
364	jmapID, err := p.lookupJMAPID(uid)
365	if err != nil {
366		return err
367	}
368
369	req := &jmapclient.Request{}
370	req.Invoke(&email.Set{
371		Account: p.accountID,
372		Update: map[jmapclient.ID]jmapclient.Patch{
373			jmapID: {"keywords/$seen": true},
374		},
375	})
376
377	_, err = p.client.Do(req)
378	return err
379}
380
381func (p *Provider) DeleteEmail(_ context.Context, _ string, uid uint32) error {
382	jmapID, err := p.lookupJMAPID(uid)
383	if err != nil {
384		return err
385	}
386
387	trashID, ok := p.roleToID[mailbox.RoleTrash]
388	if !ok {
389		// No trash, permanently delete
390		req := &jmapclient.Request{}
391		req.Invoke(&email.Set{
392			Account: p.accountID,
393			Destroy: []jmapclient.ID{jmapID},
394		})
395		_, err = p.client.Do(req)
396		return err
397	}
398
399	// Move to trash
400	req := &jmapclient.Request{}
401	req.Invoke(&email.Set{
402		Account: p.accountID,
403		Update: map[jmapclient.ID]jmapclient.Patch{
404			jmapID: {"mailboxIds": map[jmapclient.ID]bool{trashID: true}},
405		},
406	})
407	_, err = p.client.Do(req)
408	return err
409}
410
411func (p *Provider) ArchiveEmail(_ context.Context, _ string, uid uint32) error {
412	jmapID, err := p.lookupJMAPID(uid)
413	if err != nil {
414		return err
415	}
416
417	archiveID, ok := p.roleToID[mailbox.RoleArchive]
418	if !ok {
419		return fmt.Errorf("jmap: no archive mailbox found")
420	}
421
422	req := &jmapclient.Request{}
423	req.Invoke(&email.Set{
424		Account: p.accountID,
425		Update: map[jmapclient.ID]jmapclient.Patch{
426			jmapID: {"mailboxIds": map[jmapclient.ID]bool{archiveID: true}},
427		},
428	})
429	_, err = p.client.Do(req)
430	return err
431}
432
433func (p *Provider) MoveEmail(_ context.Context, uid uint32, _, dstFolder string) error {
434	jmapID, err := p.lookupJMAPID(uid)
435	if err != nil {
436		return err
437	}
438
439	dstID, err := p.resolveMailboxID(dstFolder)
440	if err != nil {
441		return err
442	}
443
444	req := &jmapclient.Request{}
445	req.Invoke(&email.Set{
446		Account: p.accountID,
447		Update: map[jmapclient.ID]jmapclient.Patch{
448			jmapID: {"mailboxIds": map[jmapclient.ID]bool{dstID: true}},
449		},
450	})
451	_, err = p.client.Do(req)
452	return err
453}
454
455func (p *Provider) DeleteEmails(ctx context.Context, folder string, uids []uint32) error {
456	// JMAP can handle batch operations - loop through for now
457	for _, uid := range uids {
458		if err := p.DeleteEmail(ctx, folder, uid); err != nil {
459			return err
460		}
461	}
462	return nil
463}
464
465func (p *Provider) ArchiveEmails(ctx context.Context, folder string, uids []uint32) error {
466	// JMAP can handle batch operations - loop through for now
467	for _, uid := range uids {
468		if err := p.ArchiveEmail(ctx, folder, uid); err != nil {
469			return err
470		}
471	}
472	return nil
473}
474
475func (p *Provider) MoveEmails(ctx context.Context, uids []uint32, srcFolder, dstFolder string) error {
476	// JMAP can handle batch operations - loop through for now
477	for _, uid := range uids {
478		if err := p.MoveEmail(ctx, uid, srcFolder, dstFolder); err != nil {
479			return err
480		}
481	}
482	return nil
483}
484
485func (p *Provider) SendEmail(_ context.Context, msg *backend.OutgoingEmail) error {
486	// Build the email as a draft first
487	toAddrs := make([]*mail.Address, len(msg.To))
488	for i, addr := range msg.To {
489		toAddrs[i] = &mail.Address{Email: addr}
490	}
491	ccAddrs := make([]*mail.Address, len(msg.Cc))
492	for i, addr := range msg.Cc {
493		ccAddrs[i] = &mail.Address{Email: addr}
494	}
495
496	// Build raw RFC5322 message and upload as blob
497	var buf bytes.Buffer
498	fmt.Fprintf(&buf, "From: %s\r\n", p.account.FormatFromHeader())
499	fmt.Fprintf(&buf, "To: %s\r\n", strings.Join(msg.To, ", "))
500	if len(msg.Cc) > 0 {
501		fmt.Fprintf(&buf, "Cc: %s\r\n", strings.Join(msg.Cc, ", "))
502	}
503	fmt.Fprintf(&buf, "Subject: %s\r\n", msg.Subject)
504	fmt.Fprintf(&buf, "Date: %s\r\n", time.Now().Format(time.RFC1123Z))
505	if msg.InReplyTo != "" {
506		fmt.Fprintf(&buf, "In-Reply-To: %s\r\n", msg.InReplyTo)
507	}
508	if len(msg.References) > 0 {
509		fmt.Fprintf(&buf, "References: %s\r\n", strings.Join(msg.References, " "))
510	}
511	fmt.Fprintf(&buf, "MIME-Version: 1.0\r\n")
512
513	body := msg.HTMLBody
514	ct := "text/html"
515	if body == "" {
516		body = msg.PlainBody
517		ct = "text/plain"
518	}
519	fmt.Fprintf(&buf, "Content-Type: %s; charset=utf-8\r\n", ct)
520	fmt.Fprintf(&buf, "\r\n%s", body)
521
522	// Upload the blob
523	uploadResp, err := p.client.Upload(p.accountID, &buf)
524	if err != nil {
525		return fmt.Errorf("jmap upload: %w", err)
526	}
527
528	// Create the email from the blob via Email/import would be ideal,
529	// but we can use Email/set create with the uploaded blob
530	draftsID := p.roleToID[mailbox.RoleDrafts]
531	if draftsID == "" {
532		// Use inbox as fallback
533		draftsID = p.roleToID[mailbox.RoleInbox]
534	}
535
536	req := &jmapclient.Request{}
537
538	// Import the uploaded blob as an email
539	createID := jmapclient.ID("draft")
540	req.Invoke(&email.Set{
541		Account: p.accountID,
542		Create: map[jmapclient.ID]*email.Email{
543			createID: {
544				BlobID:     uploadResp.ID,
545				MailboxIDs: map[jmapclient.ID]bool{draftsID: true},
546				Keywords:   map[string]bool{"$draft": true, "$seen": true},
547			},
548		},
549	})
550
551	// Build envelope recipients
552	var rcptTo []*emailsubmission.Address
553	for _, addr := range msg.To {
554		rcptTo = append(rcptTo, &emailsubmission.Address{Email: addr})
555	}
556	for _, addr := range msg.Cc {
557		rcptTo = append(rcptTo, &emailsubmission.Address{Email: addr})
558	}
559	for _, addr := range msg.Bcc {
560		rcptTo = append(rcptTo, &emailsubmission.Address{Email: addr})
561	}
562
563	sentID := p.roleToID[mailbox.RoleSent]
564
565	// Submit for sending
566	subReq := &emailsubmission.Set{
567		Account: p.accountID,
568		Create: map[jmapclient.ID]*emailsubmission.EmailSubmission{
569			"sub": {
570				EmailID: "#draft",
571				Envelope: &emailsubmission.Envelope{
572					MailFrom: &emailsubmission.Address{Email: p.account.Email},
573					RcptTo:   rcptTo,
574				},
575			},
576		},
577	}
578	if sentID != "" {
579		subReq.OnSuccessUpdateEmail = map[jmapclient.ID]jmapclient.Patch{
580			"#sub": {
581				"mailboxIds":      map[jmapclient.ID]bool{sentID: true},
582				"keywords/$draft": nil,
583			},
584		}
585	}
586	req.Invoke(subReq)
587
588	_, err = p.client.Do(req)
589	return err
590}
591
592func (p *Provider) FetchFolders(_ context.Context) ([]backend.Folder, error) {
593	if err := p.refreshMailboxes(); err != nil {
594		return nil, err
595	}
596
597	req := &jmapclient.Request{}
598	req.Invoke(&mailbox.Get{
599		Account: p.accountID,
600	})
601
602	resp, err := p.client.Do(req)
603	if err != nil {
604		return nil, err
605	}
606
607	var folders []backend.Folder
608	for _, inv := range resp.Responses {
609		if r, ok := inv.Args.(*mailbox.GetResponse); ok {
610			for _, mbox := range r.List {
611				folders = append(folders, backend.Folder{
612					Name:      mbox.Name,
613					Delimiter: "/",
614				})
615			}
616		}
617	}
618
619	return folders, nil
620}
621
622func (p *Provider) Watch(_ context.Context, _ string) (<-chan backend.NotifyEvent, func(), error) {
623	ch := make(chan backend.NotifyEvent, 16)
624
625	es := &push.EventSource{
626		Client: p.client,
627		Handler: func(change *jmapclient.StateChange) {
628			for _, typeState := range change.Changed {
629				for objType := range typeState {
630					if objType == "Email" || objType == "Mailbox" {
631						ch <- backend.NotifyEvent{
632							Type:      backend.NotifyNewEmail,
633							AccountID: p.account.ID,
634						}
635					}
636				}
637			}
638		},
639		Ping: 30,
640	}
641
642	go func() {
643		defer close(ch)
644		_ = es.Listen()
645	}()
646
647	cancel := func() {
648		es.Close()
649	}
650
651	return ch, cancel, nil
652}
653
654func (p *Provider) Close() error {
655	return nil
656}
657
658// Verify interface compliance at compile time.
659var _ backend.Provider = (*Provider)(nil)
660
661// lookupJMAPID resolves a uint32 UID hash back to the JMAP string ID.
662func (p *Provider) lookupJMAPID(uid uint32) (jmapclient.ID, error) {
663	p.mu.Lock()
664	defer p.mu.Unlock()
665	id, ok := p.idToJMAPID[uid]
666	if !ok {
667		return "", fmt.Errorf("jmap: no cached ID for UID %d", uid)
668	}
669	return id, nil
670}
671
672// jmapIDToUID converts a JMAP string ID to a uint32 hash for use as a UID.
673func jmapIDToUID(id jmapclient.ID) uint32 {
674	h := fnv.New32a()
675	h.Write([]byte(id))
676	v := h.Sum32()
677	if v == 0 {
678		v = 1
679	}
680	return v
681}
682
683// jmapEmailToBackend converts a JMAP email to a backend.Email.
684func jmapEmailToBackend(eml *email.Email, uid uint32, accountID string) backend.Email {
685	e := backend.Email{
686		UID:       uid,
687		Subject:   eml.Subject,
688		Date:      safeTime(eml.ReceivedAt),
689		IsRead:    eml.Keywords["$seen"],
690		AccountID: accountID,
691	}
692	if len(eml.From) > 0 {
693		e.From = eml.From[0].String()
694	}
695	for _, addr := range eml.To {
696		e.To = append(e.To, addr.Email)
697	}
698	for _, addr := range eml.ReplyTo {
699		e.ReplyTo = append(e.ReplyTo, addr.Email)
700	}
701	if len(eml.MessageID) > 0 {
702		e.MessageID = eml.MessageID[0]
703	}
704	if len(eml.InReplyTo) > 0 {
705		e.InReplyTo = eml.InReplyTo[0]
706	}
707	e.References = append(e.References, eml.References...)
708	return e
709}
710
711func safeTime(t *time.Time) time.Time {
712	if t == nil {
713		return time.Time{}
714	}
715	return *t
716}