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
26const jmapMailboxIds = "mailboxIds"
27
28func init() {
29 backend.RegisterBackend("jmap", func(account *config.Account) (backend.Provider, error) {
30 return New(account)
31 })
32}
33
34// Provider implements backend.Provider using JMAP.
35type Provider struct {
36 account *config.Account
37 client *jmapclient.Client
38 accountID jmapclient.ID
39
40 mu sync.Mutex
41 mailboxes map[string]jmapclient.ID // name -> ID
42 roleToID map[mailbox.Role]jmapclient.ID
43 idToJMAPID map[uint32]jmapclient.ID // UID hash -> JMAP ID
44}
45
46// New creates a new JMAP provider.
47func New(account *config.Account) (*Provider, error) {
48 if account.JMAPEndpoint == "" {
49 return nil, fmt.Errorf("JMAP endpoint URL not configured")
50 }
51
52 client := &jmapclient.Client{
53 SessionEndpoint: account.JMAPEndpoint,
54 }
55
56 if account.AuthMethod == "oauth2" || account.AuthMethod == "token" || account.AuthMethod == "" {
57 client.WithAccessToken(account.Password)
58 } else {
59 client.WithBasicAuth(account.Email, account.Password)
60 }
61
62 if err := client.Authenticate(); err != nil {
63 return nil, fmt.Errorf("jmap auth: %w", err)
64 }
65
66 acctID := client.Session.PrimaryAccounts[mail.URI]
67 if acctID == "" {
68 return nil, fmt.Errorf("jmap: no mail account found in session")
69 }
70
71 p := &Provider{
72 account: account,
73 client: client,
74 accountID: acctID,
75 mailboxes: make(map[string]jmapclient.ID),
76 roleToID: make(map[mailbox.Role]jmapclient.ID),
77 idToJMAPID: make(map[uint32]jmapclient.ID),
78 }
79
80 // Pre-fetch mailbox list
81 if err := p.refreshMailboxes(); err != nil {
82 return nil, fmt.Errorf("jmap mailboxes: %w", err)
83 }
84
85 return p, nil
86}
87
88func (p *Provider) refreshMailboxes() error {
89 req := &jmapclient.Request{}
90 req.Invoke(&mailbox.Get{
91 Account: p.accountID,
92 })
93
94 resp, err := p.client.Do(req)
95 if err != nil {
96 return err
97 }
98
99 p.mu.Lock()
100 defer p.mu.Unlock()
101
102 for _, inv := range resp.Responses {
103 if r, ok := inv.Args.(*mailbox.GetResponse); ok {
104 for _, mbox := range r.List {
105 p.mailboxes[mbox.Name] = mbox.ID
106 if mbox.Role != "" {
107 p.roleToID[mbox.Role] = mbox.ID
108 }
109 }
110 }
111 }
112 return nil
113}
114
115// resolveMailboxID maps a folder name to a JMAP mailbox ID.
116func (p *Provider) resolveMailboxID(folder string) (jmapclient.ID, error) {
117 p.mu.Lock()
118 defer p.mu.Unlock()
119
120 // Direct name match
121 if id, ok := p.mailboxes[folder]; ok {
122 return id, nil
123 }
124
125 // Role-based fallback for common folder names
126 nameToRole := map[string]mailbox.Role{
127 "INBOX": mailbox.RoleInbox,
128 "Inbox": mailbox.RoleInbox,
129 "Sent": mailbox.RoleSent,
130 "Drafts": mailbox.RoleDrafts,
131 "Trash": mailbox.RoleTrash,
132 "Junk": mailbox.RoleJunk,
133 "Spam": mailbox.RoleJunk,
134 "Archive": mailbox.RoleArchive,
135 }
136 if role, ok := nameToRole[folder]; ok {
137 if id, ok := p.roleToID[role]; ok {
138 return id, nil
139 }
140 }
141
142 return "", fmt.Errorf("jmap: mailbox %q not found", folder)
143}
144
145func (p *Provider) FetchEmails(_ context.Context, folder string, limit, offset uint32) ([]backend.Email, error) {
146 mboxID, err := p.resolveMailboxID(folder)
147 if err != nil {
148 return nil, err
149 }
150
151 req := &jmapclient.Request{}
152
153 queryCallID := req.Invoke(&email.Query{
154 Account: p.accountID,
155 Filter: &email.FilterCondition{InMailbox: mboxID},
156 Sort: []*email.SortComparator{
157 {Property: "receivedAt", IsAscending: false},
158 },
159 Position: int64(offset),
160 Limit: uint64(limit),
161 })
162
163 req.Invoke(&email.Get{
164 Account: p.accountID,
165 ReferenceIDs: &jmapclient.ResultReference{
166 ResultOf: queryCallID,
167 Name: "Email/query",
168 Path: "/ids",
169 },
170 Properties: []string{
171 "id", "subject", "from", "to", "replyTo", "receivedAt",
172 "preview", "keywords", jmapMailboxIds, "hasAttachment",
173 "messageId", "inReplyTo", "references",
174 },
175 })
176
177 resp, err := p.client.Do(req)
178 if err != nil {
179 return nil, fmt.Errorf("jmap fetch: %w", err)
180 }
181
182 var emails []backend.Email
183 for _, inv := range resp.Responses {
184 if r, ok := inv.Args.(*email.GetResponse); ok {
185 for _, eml := range r.List {
186 uid := jmapIDToUID(eml.ID)
187 p.mu.Lock()
188 p.idToJMAPID[uid] = eml.ID
189 p.mu.Unlock()
190
191 e := jmapEmailToBackend(eml, uid, p.account.ID)
192 emails = append(emails, e)
193 }
194 }
195 }
196
197 return emails, nil
198}
199
200func (p *Provider) Search(_ context.Context, folder string, query backend.SearchQuery) ([]backend.Email, error) {
201 mboxID, err := p.resolveMailboxID(folder)
202 if err != nil {
203 return nil, err
204 }
205
206 req := &jmapclient.Request{}
207 queryCallID := req.Invoke(&email.Query{
208 Account: p.accountID,
209 Filter: buildSearchFilter(mboxID, query),
210 Sort: []*email.SortComparator{
211 {Property: "receivedAt", IsAscending: false},
212 },
213 Limit: uint64(searchLimit(query)),
214 })
215
216 req.Invoke(&email.Get{
217 Account: p.accountID,
218 ReferenceIDs: &jmapclient.ResultReference{
219 ResultOf: queryCallID,
220 Name: "Email/query",
221 Path: "/ids",
222 },
223 Properties: []string{
224 "id", "subject", "from", "to", "replyTo", "receivedAt",
225 "preview", "keywords", jmapMailboxIds, "hasAttachment",
226 "messageId",
227 },
228 })
229
230 resp, err := p.client.Do(req)
231 if err != nil {
232 return nil, fmt.Errorf("jmap search: %w", err)
233 }
234
235 var emails []backend.Email
236 for _, inv := range resp.Responses {
237 if r, ok := inv.Args.(*email.GetResponse); ok {
238 for _, eml := range r.List {
239 uid := jmapIDToUID(eml.ID)
240 p.mu.Lock()
241 p.idToJMAPID[uid] = eml.ID
242 p.mu.Unlock()
243
244 emails = append(emails, jmapEmailToBackend(eml, uid, p.account.ID))
245 }
246 }
247 }
248
249 return emails, nil
250}
251
252func buildSearchFilter(mboxID jmapclient.ID, query backend.SearchQuery) *email.FilterCondition {
253 f := &email.FilterCondition{InMailbox: mboxID}
254 if query.From != "" {
255 f.From = query.From
256 }
257 if query.To != "" {
258 f.To = query.To
259 }
260 if query.Subject != "" {
261 f.Subject = query.Subject
262 }
263 if query.Body != "" {
264 f.Body = query.Body
265 }
266 if !query.Since.IsZero() {
267 f.After = &query.Since
268 }
269 if !query.Before.IsZero() {
270 f.Before = &query.Before
271 }
272 if query.LargerThan > 0 {
273 f.MinSize = uint64(query.LargerThan)
274 }
275 return f
276}
277
278func searchLimit(query backend.SearchQuery) uint32 {
279 if query.Limit > 0 {
280 return query.Limit
281 }
282 return 100
283}
284
285func (p *Provider) FetchEmailBody(_ context.Context, folder string, uid uint32) (string, string, []backend.Attachment, error) {
286 jmapID, err := p.resolveUID(folder, uid)
287 if err != nil {
288 return "", "", nil, err
289 }
290
291 req := &jmapclient.Request{}
292 req.Invoke(&email.Get{
293 Account: p.accountID,
294 IDs: []jmapclient.ID{jmapID},
295 Properties: []string{
296 "id", "bodyValues", "htmlBody", "textBody", "attachments",
297 "bodyStructure",
298 },
299 BodyProperties: []string{"partId", "blobId", "size", "type", "name", "disposition", "cid"},
300 FetchHTMLBodyValues: true,
301 FetchTextBodyValues: true,
302 })
303
304 resp, err := p.client.Do(req)
305 if err != nil {
306 return "", "", nil, fmt.Errorf("jmap body: %w", err)
307 }
308
309 for _, inv := range resp.Responses {
310 if r, ok := inv.Args.(*email.GetResponse); ok && len(r.List) > 0 {
311 eml := r.List[0]
312
313 // Get body text (prefer HTML)
314 var body, mimeType string
315 for _, part := range eml.HTMLBody {
316 if val, ok := eml.BodyValues[part.PartID]; ok {
317 body = val.Value
318 mimeType = "text/html"
319 break
320 }
321 }
322 if body == "" {
323 for _, part := range eml.TextBody {
324 if val, ok := eml.BodyValues[part.PartID]; ok {
325 body = val.Value
326 mimeType = "text/plain"
327 break
328 }
329 }
330 }
331
332 // Get attachments
333 var atts []backend.Attachment
334 for _, att := range eml.Attachments {
335 a := backend.Attachment{
336 Filename: att.Name,
337 PartID: string(att.BlobID),
338 MIMEType: att.Type,
339 Inline: att.Disposition == "inline",
340 }
341 if att.CID != "" {
342 a.ContentID = strings.Trim(att.CID, "<>")
343 }
344 atts = append(atts, a)
345 }
346
347 return body, mimeType, atts, nil
348 }
349 }
350
351 return "", "", nil, fmt.Errorf("jmap: email not found")
352}
353
354func (p *Provider) FetchAttachment(_ context.Context, _ string, _ uint32, partID, _ string) ([]byte, error) {
355 // partID is the blobId for JMAP
356 blobID := jmapclient.ID(partID)
357 reader, err := p.client.Download(p.accountID, blobID)
358 if err != nil {
359 return nil, fmt.Errorf("jmap download: %w", err)
360 }
361 defer reader.Close() //nolint:errcheck
362 return io.ReadAll(reader)
363}
364
365func (p *Provider) MarkAsRead(_ context.Context, folder string, uid uint32) error {
366 jmapID, err := p.resolveUID(folder, uid)
367 if err != nil {
368 return err
369 }
370
371 req := &jmapclient.Request{}
372 req.Invoke(&email.Set{
373 Account: p.accountID,
374 Update: map[jmapclient.ID]jmapclient.Patch{
375 jmapID: {"keywords/$seen": true},
376 },
377 })
378
379 _, err = p.client.Do(req)
380 return err
381}
382
383func (p *Provider) MarkAsUnread(_ context.Context, folder string, uid uint32) error {
384 jmapID, err := p.resolveUID(folder, uid)
385 if err != nil {
386 return err
387 }
388
389 req := &jmapclient.Request{}
390 req.Invoke(&email.Set{
391 Account: p.accountID,
392 Update: map[jmapclient.ID]jmapclient.Patch{
393 jmapID: {"keywords/$seen": nil},
394 },
395 })
396
397 _, err = p.client.Do(req)
398 return err
399}
400
401func (p *Provider) DeleteEmail(_ context.Context, folder string, uid uint32) error {
402 jmapID, err := p.resolveUID(folder, uid)
403 if err != nil {
404 return err
405 }
406
407 trashID, ok := p.roleToID[mailbox.RoleTrash]
408 if !ok {
409 // No trash, permanently delete
410 req := &jmapclient.Request{}
411 req.Invoke(&email.Set{
412 Account: p.accountID,
413 Destroy: []jmapclient.ID{jmapID},
414 })
415 _, err = p.client.Do(req)
416 return err
417 }
418
419 // Move to trash
420 req := &jmapclient.Request{}
421 req.Invoke(&email.Set{
422 Account: p.accountID,
423 Update: map[jmapclient.ID]jmapclient.Patch{
424 jmapID: {jmapMailboxIds: map[jmapclient.ID]bool{trashID: true}},
425 },
426 })
427 _, err = p.client.Do(req)
428 return err
429}
430
431func (p *Provider) ArchiveEmail(_ context.Context, folder string, uid uint32) error {
432 jmapID, err := p.resolveUID(folder, uid)
433 if err != nil {
434 return err
435 }
436
437 archiveID, ok := p.roleToID[mailbox.RoleArchive]
438 if !ok {
439 return fmt.Errorf("jmap: no archive mailbox found")
440 }
441
442 req := &jmapclient.Request{}
443 req.Invoke(&email.Set{
444 Account: p.accountID,
445 Update: map[jmapclient.ID]jmapclient.Patch{
446 jmapID: {jmapMailboxIds: map[jmapclient.ID]bool{archiveID: true}},
447 },
448 })
449 _, err = p.client.Do(req)
450 return err
451}
452
453func (p *Provider) MoveEmail(_ context.Context, uid uint32, srcFolder, dstFolder string) error {
454 jmapID, err := p.resolveUID(srcFolder, uid)
455 if err != nil {
456 return err
457 }
458
459 dstID, err := p.resolveMailboxID(dstFolder)
460 if err != nil {
461 return err
462 }
463
464 req := &jmapclient.Request{}
465 req.Invoke(&email.Set{
466 Account: p.accountID,
467 Update: map[jmapclient.ID]jmapclient.Patch{
468 jmapID: {jmapMailboxIds: map[jmapclient.ID]bool{dstID: true}},
469 },
470 })
471 _, err = p.client.Do(req)
472 return err
473}
474
475func (p *Provider) DeleteEmails(ctx context.Context, folder string, uids []uint32) error {
476 // JMAP can handle batch operations - loop through for now
477 for _, uid := range uids {
478 if err := p.DeleteEmail(ctx, folder, uid); err != nil {
479 return err
480 }
481 }
482 return nil
483}
484
485func (p *Provider) ArchiveEmails(ctx context.Context, folder string, uids []uint32) error {
486 // JMAP can handle batch operations - loop through for now
487 for _, uid := range uids {
488 if err := p.ArchiveEmail(ctx, folder, uid); err != nil {
489 return err
490 }
491 }
492 return nil
493}
494
495func (p *Provider) MoveEmails(ctx context.Context, uids []uint32, srcFolder, dstFolder string) error {
496 // JMAP can handle batch operations - loop through for now
497 for _, uid := range uids {
498 if err := p.MoveEmail(ctx, uid, srcFolder, dstFolder); err != nil {
499 return err
500 }
501 }
502 return nil
503}
504
505func (p *Provider) SendEmail(_ context.Context, msg *backend.OutgoingEmail) error {
506 // Build the email as a draft first
507 toAddrs := make([]*mail.Address, len(msg.To))
508 for i, addr := range msg.To {
509 toAddrs[i] = &mail.Address{Email: addr}
510 }
511 ccAddrs := make([]*mail.Address, len(msg.Cc))
512 for i, addr := range msg.Cc {
513 ccAddrs[i] = &mail.Address{Email: addr}
514 }
515
516 // Build raw RFC5322 message and upload as blob
517 var buf bytes.Buffer
518 fmt.Fprintf(&buf, "From: %s\r\n", p.account.FormatFromHeader())
519 fmt.Fprintf(&buf, "To: %s\r\n", strings.Join(msg.To, ", "))
520 if len(msg.Cc) > 0 {
521 fmt.Fprintf(&buf, "Cc: %s\r\n", strings.Join(msg.Cc, ", "))
522 }
523 fmt.Fprintf(&buf, "Subject: %s\r\n", msg.Subject)
524 fmt.Fprintf(&buf, "Date: %s\r\n", time.Now().Format(time.RFC1123Z))
525 if msg.InReplyTo != "" {
526 fmt.Fprintf(&buf, "In-Reply-To: %s\r\n", msg.InReplyTo)
527 }
528 if len(msg.References) > 0 {
529 fmt.Fprintf(&buf, "References: %s\r\n", strings.Join(msg.References, " "))
530 }
531 fmt.Fprintf(&buf, "MIME-Version: 1.0\r\n")
532
533 body := msg.HTMLBody
534 ct := "text/html"
535 if body == "" {
536 body = msg.PlainBody
537 ct = "text/plain"
538 }
539 fmt.Fprintf(&buf, "Content-Type: %s; charset=utf-8\r\n", ct)
540 fmt.Fprintf(&buf, "\r\n%s", body)
541
542 // Upload the blob
543 uploadResp, err := p.client.Upload(p.accountID, &buf)
544 if err != nil {
545 return fmt.Errorf("jmap upload: %w", err)
546 }
547
548 // Create the email from the blob via Email/import would be ideal,
549 // but we can use Email/set create with the uploaded blob
550 draftsID := p.roleToID[mailbox.RoleDrafts]
551 if draftsID == "" {
552 // Use inbox as fallback
553 draftsID = p.roleToID[mailbox.RoleInbox]
554 }
555
556 req := &jmapclient.Request{}
557
558 // Import the uploaded blob as an email
559 createID := jmapclient.ID("draft")
560 req.Invoke(&email.Set{
561 Account: p.accountID,
562 Create: map[jmapclient.ID]*email.Email{
563 createID: {
564 BlobID: uploadResp.ID,
565 MailboxIDs: map[jmapclient.ID]bool{draftsID: true},
566 Keywords: map[string]bool{"$draft": true, "$seen": true},
567 },
568 },
569 })
570
571 // Build envelope recipients
572 var rcptTo []*emailsubmission.Address
573 for _, addr := range msg.To {
574 rcptTo = append(rcptTo, &emailsubmission.Address{Email: addr})
575 }
576 for _, addr := range msg.Cc {
577 rcptTo = append(rcptTo, &emailsubmission.Address{Email: addr})
578 }
579 for _, addr := range msg.Bcc {
580 rcptTo = append(rcptTo, &emailsubmission.Address{Email: addr})
581 }
582
583 sentID := p.roleToID[mailbox.RoleSent]
584
585 // Submit for sending
586 subReq := &emailsubmission.Set{
587 Account: p.accountID,
588 Create: map[jmapclient.ID]*emailsubmission.EmailSubmission{
589 "sub": {
590 EmailID: "#draft",
591 Envelope: &emailsubmission.Envelope{
592 MailFrom: &emailsubmission.Address{Email: p.account.Email},
593 RcptTo: rcptTo,
594 },
595 },
596 },
597 }
598 if sentID != "" {
599 subReq.OnSuccessUpdateEmail = map[jmapclient.ID]jmapclient.Patch{
600 "#sub": {
601 jmapMailboxIds: map[jmapclient.ID]bool{sentID: true},
602 "keywords/$draft": nil,
603 },
604 }
605 }
606 req.Invoke(subReq)
607
608 _, err = p.client.Do(req)
609 return err
610}
611
612func (p *Provider) FetchFolders(_ context.Context) ([]backend.Folder, error) {
613 if err := p.refreshMailboxes(); err != nil {
614 return nil, err
615 }
616
617 req := &jmapclient.Request{}
618 req.Invoke(&mailbox.Get{
619 Account: p.accountID,
620 })
621
622 resp, err := p.client.Do(req)
623 if err != nil {
624 return nil, err
625 }
626
627 var folders []backend.Folder
628 for _, inv := range resp.Responses {
629 if r, ok := inv.Args.(*mailbox.GetResponse); ok {
630 for _, mbox := range r.List {
631 folders = append(folders, backend.Folder{
632 Name: mbox.Name,
633 Delimiter: "/",
634 })
635 }
636 }
637 }
638
639 return folders, nil
640}
641
642func (p *Provider) Watch(_ context.Context, _ string) (<-chan backend.NotifyEvent, func(), error) {
643 ch := make(chan backend.NotifyEvent, 16)
644
645 es := &push.EventSource{
646 Client: p.client,
647 Handler: func(change *jmapclient.StateChange) {
648 for _, typeState := range change.Changed {
649 for objType := range typeState {
650 if objType == "Email" || objType == "Mailbox" {
651 ch <- backend.NotifyEvent{
652 Type: backend.NotifyNewEmail,
653 AccountID: p.account.ID,
654 }
655 }
656 }
657 }
658 },
659 Ping: 30,
660 }
661
662 go func() {
663 defer close(ch)
664 _ = es.Listen()
665 }()
666
667 cancel := func() {
668 es.Close()
669 }
670
671 return ch, cancel, nil
672}
673
674func (p *Provider) Close() error {
675 return nil
676}
677
678// Verify interface compliance at compile time.
679var _ backend.Provider = (*Provider)(nil)
680
681// resolveUID returns the JMAP ID for the given uint32 UID. It checks the
682// in-memory cache first (fast path when FetchEmails ran on the same instance),
683// then falls back to querying the mailbox so the backend works correctly even
684// when a fresh Provider instance is created per call.
685func (p *Provider) resolveUID(folder string, uid uint32) (jmapclient.ID, error) {
686 if id, err := p.lookupJMAPID(uid); err == nil {
687 return id, nil
688 }
689 return p.resolveUIDByQuery(folder, uid)
690}
691
692// resolveUIDByQuery fetches all email IDs in the folder from JMAP via
693// Email/query, hashes each one, and returns the ID whose hash matches uid.
694// It also warms the local cache as a side effect.
695func (p *Provider) resolveUIDByQuery(folder string, uid uint32) (jmapclient.ID, error) {
696 mboxID, err := p.resolveMailboxID(folder)
697 if err != nil {
698 return "", fmt.Errorf("jmap: resolving mailbox for UID lookup: %w", err)
699 }
700
701 req := &jmapclient.Request{}
702 req.Invoke(&email.Query{
703 Account: p.accountID,
704 Filter: &email.FilterCondition{InMailbox: mboxID},
705 Limit: 10000,
706 })
707
708 resp, err := p.client.Do(req)
709 if err != nil {
710 return "", fmt.Errorf("jmap: querying IDs for UID lookup: %w", err)
711 }
712
713 p.mu.Lock()
714 defer p.mu.Unlock()
715
716 for _, inv := range resp.Responses {
717 if r, ok := inv.Args.(*email.QueryResponse); ok {
718 for _, id := range r.IDs {
719 h := jmapIDToUID(id)
720 p.idToJMAPID[h] = id
721 if h == uid {
722 return id, nil
723 }
724 }
725 }
726 }
727 return "", fmt.Errorf("jmap: no email found for UID %d in folder %q", uid, folder)
728}
729
730// lookupJMAPID resolves a uint32 UID hash back to the JMAP string ID.
731func (p *Provider) lookupJMAPID(uid uint32) (jmapclient.ID, error) {
732 p.mu.Lock()
733 defer p.mu.Unlock()
734 id, ok := p.idToJMAPID[uid]
735 if !ok {
736 return "", fmt.Errorf("jmap: no cached ID for UID %d", uid)
737 }
738 return id, nil
739}
740
741// jmapIDToUID converts a JMAP string ID to a uint32 hash for use as a UID.
742func jmapIDToUID(id jmapclient.ID) uint32 {
743 h := fnv.New32a()
744 h.Write([]byte(id)) //nolint:gosec
745 v := h.Sum32()
746 if v == 0 {
747 v = 1
748 }
749 return v
750}
751
752// jmapEmailToBackend converts a JMAP email to a backend.Email.
753func jmapEmailToBackend(eml *email.Email, uid uint32, accountID string) backend.Email {
754 e := backend.Email{
755 UID: uid,
756 Subject: eml.Subject,
757 Date: safeTime(eml.ReceivedAt),
758 IsRead: eml.Keywords["$seen"],
759 AccountID: accountID,
760 }
761 if len(eml.From) > 0 {
762 e.From = eml.From[0].String()
763 }
764 for _, addr := range eml.To {
765 e.To = append(e.To, addr.Email)
766 }
767 for _, addr := range eml.ReplyTo {
768 e.ReplyTo = append(e.ReplyTo, addr.Email)
769 }
770 if len(eml.MessageID) > 0 {
771 e.MessageID = eml.MessageID[0]
772 }
773 if len(eml.InReplyTo) > 0 {
774 e.InReplyTo = eml.InReplyTo[0]
775 }
776 e.References = append(e.References, eml.References...)
777 return e
778}
779
780func safeTime(t *time.Time) time.Time {
781 if t == nil {
782 return time.Time{}
783 }
784 return *t
785}