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