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