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