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