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) SendEmail(_ context.Context, msg *backend.OutgoingEmail) error {
384 // Build the email as a draft first
385 toAddrs := make([]*mail.Address, len(msg.To))
386 for i, addr := range msg.To {
387 toAddrs[i] = &mail.Address{Email: addr}
388 }
389 ccAddrs := make([]*mail.Address, len(msg.Cc))
390 for i, addr := range msg.Cc {
391 ccAddrs[i] = &mail.Address{Email: addr}
392 }
393
394 // Build raw RFC5322 message and upload as blob
395 var buf bytes.Buffer
396 fmt.Fprintf(&buf, "From: %s\r\n", p.account.Email)
397 fmt.Fprintf(&buf, "To: %s\r\n", strings.Join(msg.To, ", "))
398 if len(msg.Cc) > 0 {
399 fmt.Fprintf(&buf, "Cc: %s\r\n", strings.Join(msg.Cc, ", "))
400 }
401 fmt.Fprintf(&buf, "Subject: %s\r\n", msg.Subject)
402 fmt.Fprintf(&buf, "Date: %s\r\n", time.Now().Format(time.RFC1123Z))
403 if msg.InReplyTo != "" {
404 fmt.Fprintf(&buf, "In-Reply-To: %s\r\n", msg.InReplyTo)
405 }
406 if len(msg.References) > 0 {
407 fmt.Fprintf(&buf, "References: %s\r\n", strings.Join(msg.References, " "))
408 }
409 fmt.Fprintf(&buf, "MIME-Version: 1.0\r\n")
410
411 body := msg.HTMLBody
412 ct := "text/html"
413 if body == "" {
414 body = msg.PlainBody
415 ct = "text/plain"
416 }
417 fmt.Fprintf(&buf, "Content-Type: %s; charset=utf-8\r\n", ct)
418 fmt.Fprintf(&buf, "\r\n%s", body)
419
420 // Upload the blob
421 uploadResp, err := p.client.Upload(p.accountID, &buf)
422 if err != nil {
423 return fmt.Errorf("jmap upload: %w", err)
424 }
425
426 // Create the email from the blob via Email/import would be ideal,
427 // but we can use Email/set create with the uploaded blob
428 draftsID := p.roleToID[mailbox.RoleDrafts]
429 if draftsID == "" {
430 // Use inbox as fallback
431 draftsID = p.roleToID[mailbox.RoleInbox]
432 }
433
434 req := &jmapclient.Request{}
435
436 // Import the uploaded blob as an email
437 createID := jmapclient.ID("draft")
438 req.Invoke(&email.Set{
439 Account: p.accountID,
440 Create: map[jmapclient.ID]*email.Email{
441 createID: {
442 BlobID: uploadResp.ID,
443 MailboxIDs: map[jmapclient.ID]bool{draftsID: true},
444 Keywords: map[string]bool{"$draft": true, "$seen": true},
445 },
446 },
447 })
448
449 // Build envelope recipients
450 var rcptTo []*emailsubmission.Address
451 for _, addr := range msg.To {
452 rcptTo = append(rcptTo, &emailsubmission.Address{Email: addr})
453 }
454 for _, addr := range msg.Cc {
455 rcptTo = append(rcptTo, &emailsubmission.Address{Email: addr})
456 }
457 for _, addr := range msg.Bcc {
458 rcptTo = append(rcptTo, &emailsubmission.Address{Email: addr})
459 }
460
461 sentID := p.roleToID[mailbox.RoleSent]
462
463 // Submit for sending
464 subReq := &emailsubmission.Set{
465 Account: p.accountID,
466 Create: map[jmapclient.ID]*emailsubmission.EmailSubmission{
467 "sub": {
468 EmailID: "#draft",
469 Envelope: &emailsubmission.Envelope{
470 MailFrom: &emailsubmission.Address{Email: p.account.Email},
471 RcptTo: rcptTo,
472 },
473 },
474 },
475 }
476 if sentID != "" {
477 subReq.OnSuccessUpdateEmail = map[jmapclient.ID]jmapclient.Patch{
478 "#sub": {
479 "mailboxIds": map[jmapclient.ID]bool{sentID: true},
480 "keywords/$draft": nil,
481 },
482 }
483 }
484 req.Invoke(subReq)
485
486 _, err = p.client.Do(req)
487 return err
488}
489
490func (p *Provider) FetchFolders(_ context.Context) ([]backend.Folder, error) {
491 if err := p.refreshMailboxes(); err != nil {
492 return nil, err
493 }
494
495 req := &jmapclient.Request{}
496 req.Invoke(&mailbox.Get{
497 Account: p.accountID,
498 })
499
500 resp, err := p.client.Do(req)
501 if err != nil {
502 return nil, err
503 }
504
505 var folders []backend.Folder
506 for _, inv := range resp.Responses {
507 if r, ok := inv.Args.(*mailbox.GetResponse); ok {
508 for _, mbox := range r.List {
509 folders = append(folders, backend.Folder{
510 Name: mbox.Name,
511 Delimiter: "/",
512 })
513 }
514 }
515 }
516
517 return folders, nil
518}
519
520func (p *Provider) Watch(_ context.Context, _ string) (<-chan backend.NotifyEvent, func(), error) {
521 ch := make(chan backend.NotifyEvent, 16)
522
523 es := &push.EventSource{
524 Client: p.client,
525 Handler: func(change *jmapclient.StateChange) {
526 for _, typeState := range change.Changed {
527 for objType := range typeState {
528 if objType == "Email" || objType == "Mailbox" {
529 ch <- backend.NotifyEvent{
530 Type: backend.NotifyNewEmail,
531 AccountID: p.account.ID,
532 }
533 }
534 }
535 }
536 },
537 Ping: 30,
538 }
539
540 go func() {
541 defer close(ch)
542 _ = es.Listen()
543 }()
544
545 cancel := func() {
546 es.Close()
547 }
548
549 return ch, cancel, nil
550}
551
552func (p *Provider) Close() error {
553 return nil
554}
555
556// Verify interface compliance at compile time.
557var _ backend.Provider = (*Provider)(nil)
558
559// lookupJMAPID resolves a uint32 UID hash back to the JMAP string ID.
560func (p *Provider) lookupJMAPID(uid uint32) (jmapclient.ID, error) {
561 p.mu.Lock()
562 defer p.mu.Unlock()
563 id, ok := p.idToJMAPID[uid]
564 if !ok {
565 return "", fmt.Errorf("jmap: no cached ID for UID %d", uid)
566 }
567 return id, nil
568}
569
570// jmapIDToUID converts a JMAP string ID to a uint32 hash for use as a UID.
571func jmapIDToUID(id jmapclient.ID) uint32 {
572 h := fnv.New32a()
573 h.Write([]byte(id))
574 v := h.Sum32()
575 if v == 0 {
576 v = 1
577 }
578 return v
579}
580
581func safeTime(t *time.Time) time.Time {
582 if t == nil {
583 return time.Time{}
584 }
585 return *t
586}