• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

vocdoni / saas-backend / 15048859546

15 May 2025 03:21PM UTC coverage: 51.892% (-0.3%) from 52.143%
15048859546

Pull #112

github

emmdim
WIP
Pull Request #112: WIP

10 of 51 new or added lines in 3 files covered. (19.61%)

14 existing lines in 2 files now uncovered.

3839 of 7398 relevant lines covered (51.89%)

21.45 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

82.28
/notifications/smtp/smtp.go
1
// Package smtp provides an SMTP-based implementation of the NotificationService interface
2
// for sending email notifications.
3
package smtp
4

5
import (
6
        "bytes"
7
        "context"
8
        "fmt"
9
        "mime/multipart"
10
        "net/mail"
11
        "net/smtp"
12
        "net/textproto"
13

14
        "github.com/vocdoni/saas-backend/notifications"
15
)
16

17
var disableTrackingFilter = []byte(`{"filters":{"clicktrack":{"settings":{"enable":0,"enable_text":false}}}}`)
18

19
// Config represents the configuration for the SMTP email service. It
20
// contains the sender's name, address, SMTP username, password, server and
21
// port. The TestAPIPort is used to define the port of the API service used
22
// for testing the email service locally to check messages (for example using
23
// MailHog).
24
type Config struct {
25
        FromName     string
26
        FromAddress  string
27
        SMTPUsername string
28
        SMTPPassword string
29
        SMTPServer   string
30
        SMTPPort     int
31
        TestAPIPort  int
32
}
33

34
// Email is the implementation of the NotificationService interface for the
35
// SMTP email service. It contains the configuration and the SMTP auth. It uses
36
// the net/smtp package to send emails.
37
type Email struct {
38
        config *Config
39
        auth   smtp.Auth
40
}
41

42
// New initializes the SMTP email service with the configuration. It sets the
43
// SMTP auth if the username and password are provided. It returns an error if
44
// the configuration is invalid or if the from email could not be parsed.
45
func (se *Email) New(rawConfig any) error {
3✔
46
        // parse configuration
3✔
47
        config, ok := rawConfig.(*Config)
3✔
48
        if !ok {
3✔
49
                return fmt.Errorf("invalid SMTP configuration")
×
50
        }
×
51
        // parse from email
52
        if _, err := mail.ParseAddress(config.FromAddress); err != nil {
3✔
53
                return fmt.Errorf("could not parse from email: %v", err)
×
54
        }
×
55
        // set configuration in struct
56
        se.config = config
3✔
57
        // init SMTP auth
3✔
58
        if se.config.SMTPUsername != "" && se.config.SMTPPassword != "" {
6✔
59
                se.auth = smtp.PlainAuth("", se.config.SMTPUsername, se.config.SMTPPassword, se.config.SMTPServer)
3✔
60
        }
3✔
61
        return nil
3✔
62
}
63

64
// SendNotification sends an email notification to the recipient. It composes
65
// the email body with the notification data and sends it using the SMTP server.
66
func (se *Email) SendNotification(ctx context.Context, notification *notifications.Notification) error {
31✔
67
        // compose email body
31✔
68
        body, err := se.composeBody(notification)
31✔
69
        if err != nil {
43✔
70
                return fmt.Errorf("could not compose email body: %v", err)
12✔
71
        }
12✔
72
        // send the email
73
        server := fmt.Sprintf("%s:%d", se.config.SMTPServer, se.config.SMTPPort)
19✔
74
        // create a channel to handle errors
19✔
75
        errCh := make(chan error, 1)
19✔
76
        go func() {
38✔
77
                // send the message
19✔
78
                err := smtp.SendMail(server, se.auth, se.config.FromAddress, []string{notification.ToAddress}, body)
19✔
79
                errCh <- err
19✔
80
                close(errCh)
19✔
81
        }()
19✔
82
        // wait for the message to be sent or the context to be done
83
        select {
19✔
84
        case <-ctx.Done():
×
85
                return ctx.Err()
×
86
        case err := <-errCh:
19✔
87
                return err
19✔
88
        }
89
}
90

91
// composeBody creates the email body with the notification data. It creates a
92
// multipart email with a plain text and an HTML part. It returns the email
93
// content as a byte slice or an error if the body could not be composed.
94
func (se *Email) composeBody(notification *notifications.Notification) ([]byte, error) {
31✔
95
        // parse 'to' email
31✔
96
        to, err := mail.ParseAddress(notification.ToAddress)
31✔
97
        if err != nil {
43✔
98
                return nil, fmt.Errorf("could not parse to email: %v", err)
12✔
99
        }
12✔
100
        // create email headers
101
        var headers bytes.Buffer
19✔
102
        boundary := "----=_Part_0_123456789.123456789"
19✔
103
        headers.WriteString(fmt.Sprintf("From: %s\r\n", se.config.FromAddress))
19✔
104
        headers.WriteString(fmt.Sprintf("To: %s\r\n", to.String()))
19✔
105
        headers.WriteString(fmt.Sprintf("Subject: %s\r\n", notification.Subject))
19✔
106
        if !notification.EnableTracking {
38✔
107
                headers.WriteString(fmt.Sprintf("X-SMTPAPI: %s\r\n", disableTrackingFilter))
19✔
108
        }
19✔
109
        headers.WriteString("MIME-Version: 1.0\r\n")
19✔
110
        headers.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=\"%s\"\r\n", boundary))
19✔
111
        headers.WriteString("\r\n") // blank line between headers and body
19✔
112
        // create multipart writer
19✔
113
        var body bytes.Buffer
19✔
114
        writer := multipart.NewWriter(&body)
19✔
115
        if err := writer.SetBoundary(boundary); err != nil {
19✔
UNCOV
116
                return nil, fmt.Errorf("could not set boundary: %v", err)
×
UNCOV
117
        }
×
118
        // plain text part
119
        textPart, _ := writer.CreatePart(textproto.MIMEHeader{
19✔
120
                "Content-Type":              {"text/plain; charset=\"UTF-8\""},
19✔
121
                "Content-Transfer-Encoding": {"7bit"},
19✔
122
        })
19✔
123
        if _, err := textPart.Write([]byte(notification.PlainBody)); err != nil {
19✔
124
                return nil, fmt.Errorf("could not write plain text part: %v", err)
×
UNCOV
125
        }
×
126
        // HTML part
127
        htmlPart, _ := writer.CreatePart(textproto.MIMEHeader{
19✔
128
                "Content-Type":              {"text/html; charset=\"UTF-8\""},
19✔
129
                "Content-Transfer-Encoding": {"7bit"},
19✔
130
        })
19✔
131
        if _, err := htmlPart.Write([]byte(notification.Body)); err != nil {
19✔
132
                return nil, fmt.Errorf("could not write HTML part: %v", err)
×
UNCOV
133
        }
×
134
        if err := writer.Close(); err != nil {
19✔
UNCOV
135
                return nil, fmt.Errorf("could not close writer: %v", err)
×
UNCOV
136
        }
×
137
        // combine headers and body and return the content
138
        var email bytes.Buffer
19✔
139
        email.Write(headers.Bytes())
19✔
140
        email.Write(body.Bytes())
19✔
141
        return email.Bytes(), nil
19✔
142
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc