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