Monorepo for Tangled
tangled.org
1package email
2
3import (
4 "net"
5 "net/mail"
6 "strings"
7
8 "github.com/resend/resend-go/v3"
9)
10
11type Email struct {
12 From string
13 Subject string
14 Text string
15 Html string
16 APIKey string
17}
18
19func SendEmail(email Email, recipients ...string) error {
20 client := resend.NewClient(email.APIKey)
21 _, err := client.Emails.Send(&resend.SendEmailRequest{
22 From: email.From,
23 To: recipients,
24 Subject: email.Subject,
25 Text: email.Text,
26 Html: email.Html,
27 })
28 return err
29}
30
31// AddNewsletterContact creates a global contact in Resend and adds them to the newsletter segment.
32func AddNewsletterContact(apiKey, segmentID, emailAddr string) error {
33 client := resend.NewClient(apiKey)
34 _, err := client.Contacts.Create(&resend.CreateContactRequest{
35 Email: emailAddr,
36 })
37 if err != nil {
38 return fmt.Errorf("error creating contact: %w", err)
39 }
40 _, err = client.Contacts.Segments.Add(&resend.AddContactSegmentRequest{
41 SegmentId: segmentID,
42 Email: emailAddr,
43 })
44 if err != nil {
45 return fmt.Errorf("error adding contact to newsletter segment: %w", err)
46 }
47 return nil
48}
49
50func IsValidEmail(email string) bool {
51 // Reject whitespace (ParseAddress normalizes it away)
52 if strings.ContainsAny(email, " \t\n\r") {
53 return false
54 }
55
56 // Use stdlib RFC 5322 parser
57 addr, err := mail.ParseAddress(email)
58 if err != nil {
59 return false
60 }
61
62 // Split email into local and domain parts
63 parts := strings.Split(addr.Address, "@")
64 domain := parts[1]
65
66 canonical := coalesceToCanonicalName(domain)
67 mx, err := net.LookupMX(canonical)
68
69 // Don't check err here; mx will only contain valid mx records, and we should
70 // only fallback to an implicit mx if there are no mx records defined (whether
71 // they are valid or not).
72 if len(mx) != 0 {
73 return true
74 }
75
76 if err != nil {
77 // If the domain resolves to an address, assume it's an implicit mx.
78 address, _ := net.LookupIP(canonical)
79 if len(address) != 0 {
80 return true
81 }
82 }
83
84 return false
85}
86
87func coalesceToCanonicalName(domain string) string {
88 canonical, err := net.LookupCNAME(domain)
89 if err != nil {
90 // net.LookupCNAME() returns an error if there is no cname record *and* no
91 // a/aaaa records, but there may still be mx records.
92 return domain
93 }
94 return canonical
95}