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