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}