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) MarkAsUnread(_ context.Context, _ string, uid uint32) error {
382 jmapID, err := p.lookupJMAPID(uid)
383 if err != nil {
384 return err
385 }
386
387 req := &jmapclient.Request{}
388 req.Invoke(&email.Set{
389 Account: p.accountID,
390 Update: map[jmapclient.ID]jmapclient.Patch{
391 jmapID: {"keywords/$seen": nil},
392 },
393 })
394
395 _, err = p.client.Do(req)
396 return err
397}
398
399func (p *Provider) DeleteEmail(_ context.Context, _ string, uid uint32) error {
400 jmapID, err := p.lookupJMAPID(uid)
401 if err != nil {
402 return err
403 }
404
405 trashID, ok := p.roleToID[mailbox.RoleTrash]
406 if !ok {
407 // No trash, permanently delete
408 req := &jmapclient.Request{}
409 req.Invoke(&email.Set{
410 Account: p.accountID,
411 Destroy: []jmapclient.ID{jmapID},
412 })
413 _, err = p.client.Do(req)
414 return err
415 }
416
417 // Move to trash
418 req := &jmapclient.Request{}
419 req.Invoke(&email.Set{
420 Account: p.accountID,
421 Update: map[jmapclient.ID]jmapclient.Patch{
422 jmapID: {"mailboxIds": map[jmapclient.ID]bool{trashID: true}},
423 },
424 })
425 _, err = p.client.Do(req)
426 return err
427}
428
429func (p *Provider) ArchiveEmail(_ context.Context, _ string, uid uint32) error {
430 jmapID, err := p.lookupJMAPID(uid)
431 if err != nil {
432 return err
433 }
434
435 archiveID, ok := p.roleToID[mailbox.RoleArchive]
436 if !ok {
437 return fmt.Errorf("jmap: no archive mailbox found")
438 }
439
440 req := &jmapclient.Request{}
441 req.Invoke(&email.Set{
442 Account: p.accountID,
443 Update: map[jmapclient.ID]jmapclient.Patch{
444 jmapID: {"mailboxIds": map[jmapclient.ID]bool{archiveID: true}},
445 },
446 })
447 _, err = p.client.Do(req)
448 return err
449}
450
451func (p *Provider) MoveEmail(_ context.Context, uid uint32, _, dstFolder string) error {
452 jmapID, err := p.lookupJMAPID(uid)
453 if err != nil {
454 return err
455 }
456
457 dstID, err := p.resolveMailboxID(dstFolder)
458 if err != nil {
459 return err
460 }
461
462 req := &jmapclient.Request{}
463 req.Invoke(&email.Set{
464 Account: p.accountID,
465 Update: map[jmapclient.ID]jmapclient.Patch{
466 jmapID: {"mailboxIds": map[jmapclient.ID]bool{dstID: true}},
467 },
468 })
469 _, err = p.client.Do(req)
470 return err
471}
472
473func (p *Provider) DeleteEmails(ctx context.Context, folder string, uids []uint32) error {
474 // JMAP can handle batch operations - loop through for now
475 for _, uid := range uids {
476 if err := p.DeleteEmail(ctx, folder, uid); err != nil {
477 return err
478 }
479 }
480 return nil
481}
482
483func (p *Provider) ArchiveEmails(ctx context.Context, folder string, uids []uint32) error {
484 // JMAP can handle batch operations - loop through for now
485 for _, uid := range uids {
486 if err := p.ArchiveEmail(ctx, folder, uid); err != nil {
487 return err
488 }
489 }
490 return nil
491}
492
493func (p *Provider) MoveEmails(ctx context.Context, uids []uint32, srcFolder, dstFolder string) error {
494 // JMAP can handle batch operations - loop through for now
495 for _, uid := range uids {
496 if err := p.MoveEmail(ctx, uid, srcFolder, dstFolder); err != nil {
497 return err
498 }
499 }
500 return nil
501}
502
503func (p *Provider) SendEmail(_ context.Context, msg *backend.OutgoingEmail) error {
504 // Build the email as a draft first
505 toAddrs := make([]*mail.Address, len(msg.To))
506 for i, addr := range msg.To {
507 toAddrs[i] = &mail.Address{Email: addr}
508 }
509 ccAddrs := make([]*mail.Address, len(msg.Cc))
510 for i, addr := range msg.Cc {
511 ccAddrs[i] = &mail.Address{Email: addr}
512 }
513
514 // Build raw RFC5322 message and upload as blob
515 var buf bytes.Buffer
516 fmt.Fprintf(&buf, "From: %s\r\n", p.account.FormatFromHeader())
517 fmt.Fprintf(&buf, "To: %s\r\n", strings.Join(msg.To, ", "))
518 if len(msg.Cc) > 0 {
519 fmt.Fprintf(&buf, "Cc: %s\r\n", strings.Join(msg.Cc, ", "))
520 }
521 fmt.Fprintf(&buf, "Subject: %s\r\n", msg.Subject)
522 fmt.Fprintf(&buf, "Date: %s\r\n", time.Now().Format(time.RFC1123Z))
523 if msg.InReplyTo != "" {
524 fmt.Fprintf(&buf, "In-Reply-To: %s\r\n", msg.InReplyTo)
525 }
526 if len(msg.References) > 0 {
527 fmt.Fprintf(&buf, "References: %s\r\n", strings.Join(msg.References, " "))
528 }
529 fmt.Fprintf(&buf, "MIME-Version: 1.0\r\n")
530
531 body := msg.HTMLBody
532 ct := "text/html"
533 if body == "" {
534 body = msg.PlainBody
535 ct = "text/plain"
536 }
537 fmt.Fprintf(&buf, "Content-Type: %s; charset=utf-8\r\n", ct)
538 fmt.Fprintf(&buf, "\r\n%s", body)
539
540 // Upload the blob
541 uploadResp, err := p.client.Upload(p.accountID, &buf)
542 if err != nil {
543 return fmt.Errorf("jmap upload: %w", err)
544 }
545
546 // Create the email from the blob via Email/import would be ideal,
547 // but we can use Email/set create with the uploaded blob
548 draftsID := p.roleToID[mailbox.RoleDrafts]
549 if draftsID == "" {
550 // Use inbox as fallback
551 draftsID = p.roleToID[mailbox.RoleInbox]
552 }
553
554 req := &jmapclient.Request{}
555
556 // Import the uploaded blob as an email
557 createID := jmapclient.ID("draft")
558 req.Invoke(&email.Set{
559 Account: p.accountID,
560 Create: map[jmapclient.ID]*email.Email{
561 createID: {
562 BlobID: uploadResp.ID,
563 MailboxIDs: map[jmapclient.ID]bool{draftsID: true},
564 Keywords: map[string]bool{"$draft": true, "$seen": true},
565 },
566 },
567 })
568
569 // Build envelope recipients
570 var rcptTo []*emailsubmission.Address
571 for _, addr := range msg.To {
572 rcptTo = append(rcptTo, &emailsubmission.Address{Email: addr})
573 }
574 for _, addr := range msg.Cc {
575 rcptTo = append(rcptTo, &emailsubmission.Address{Email: addr})
576 }
577 for _, addr := range msg.Bcc {
578 rcptTo = append(rcptTo, &emailsubmission.Address{Email: addr})
579 }
580
581 sentID := p.roleToID[mailbox.RoleSent]
582
583 // Submit for sending
584 subReq := &emailsubmission.Set{
585 Account: p.accountID,
586 Create: map[jmapclient.ID]*emailsubmission.EmailSubmission{
587 "sub": {
588 EmailID: "#draft",
589 Envelope: &emailsubmission.Envelope{
590 MailFrom: &emailsubmission.Address{Email: p.account.Email},
591 RcptTo: rcptTo,
592 },
593 },
594 },
595 }
596 if sentID != "" {
597 subReq.OnSuccessUpdateEmail = map[jmapclient.ID]jmapclient.Patch{
598 "#sub": {
599 "mailboxIds": map[jmapclient.ID]bool{sentID: true},
600 "keywords/$draft": nil,
601 },
602 }
603 }
604 req.Invoke(subReq)
605
606 _, err = p.client.Do(req)
607 return err
608}
609
610func (p *Provider) FetchFolders(_ context.Context) ([]backend.Folder, error) {
611 if err := p.refreshMailboxes(); err != nil {
612 return nil, err
613 }
614
615 req := &jmapclient.Request{}
616 req.Invoke(&mailbox.Get{
617 Account: p.accountID,
618 })
619
620 resp, err := p.client.Do(req)
621 if err != nil {
622 return nil, err
623 }
624
625 var folders []backend.Folder
626 for _, inv := range resp.Responses {
627 if r, ok := inv.Args.(*mailbox.GetResponse); ok {
628 for _, mbox := range r.List {
629 folders = append(folders, backend.Folder{
630 Name: mbox.Name,
631 Delimiter: "/",
632 })
633 }
634 }
635 }
636
637 return folders, nil
638}
639
640func (p *Provider) Watch(_ context.Context, _ string) (<-chan backend.NotifyEvent, func(), error) {
641 ch := make(chan backend.NotifyEvent, 16)
642
643 es := &push.EventSource{
644 Client: p.client,
645 Handler: func(change *jmapclient.StateChange) {
646 for _, typeState := range change.Changed {
647 for objType := range typeState {
648 if objType == "Email" || objType == "Mailbox" {
649 ch <- backend.NotifyEvent{
650 Type: backend.NotifyNewEmail,
651 AccountID: p.account.ID,
652 }
653 }
654 }
655 }
656 },
657 Ping: 30,
658 }
659
660 go func() {
661 defer close(ch)
662 _ = es.Listen()
663 }()
664
665 cancel := func() {
666 es.Close()
667 }
668
669 return ch, cancel, nil
670}
671
672func (p *Provider) Close() error {
673 return nil
674}
675
676// Verify interface compliance at compile time.
677var _ backend.Provider = (*Provider)(nil)
678
679// lookupJMAPID resolves a uint32 UID hash back to the JMAP string ID.
680func (p *Provider) lookupJMAPID(uid uint32) (jmapclient.ID, error) {
681 p.mu.Lock()
682 defer p.mu.Unlock()
683 id, ok := p.idToJMAPID[uid]
684 if !ok {
685 return "", fmt.Errorf("jmap: no cached ID for UID %d", uid)
686 }
687 return id, nil
688}
689
690// jmapIDToUID converts a JMAP string ID to a uint32 hash for use as a UID.
691func jmapIDToUID(id jmapclient.ID) uint32 {
692 h := fnv.New32a()
693 h.Write([]byte(id))
694 v := h.Sum32()
695 if v == 0 {
696 v = 1
697 }
698 return v
699}
700
701// jmapEmailToBackend converts a JMAP email to a backend.Email.
702func jmapEmailToBackend(eml *email.Email, uid uint32, accountID string) backend.Email {
703 e := backend.Email{
704 UID: uid,
705 Subject: eml.Subject,
706 Date: safeTime(eml.ReceivedAt),
707 IsRead: eml.Keywords["$seen"],
708 AccountID: accountID,
709 }
710 if len(eml.From) > 0 {
711 e.From = eml.From[0].String()
712 }
713 for _, addr := range eml.To {
714 e.To = append(e.To, addr.Email)
715 }
716 for _, addr := range eml.ReplyTo {
717 e.ReplyTo = append(e.ReplyTo, addr.Email)
718 }
719 if len(eml.MessageID) > 0 {
720 e.MessageID = eml.MessageID[0]
721 }
722 if len(eml.InReplyTo) > 0 {
723 e.InReplyTo = eml.InReplyTo[0]
724 }
725 e.References = append(e.References, eml.References...)
726 return e
727}
728
729func safeTime(t *time.Time) time.Time {
730 if t == nil {
731 return time.Time{}
732 }
733 return *t
734}