Skip to content

Commit 306bfa3

Browse files
authored
fix(mail): add STARTTLS and LOGIN auth support for SMTP (#1446)
* **New Features** * Email sending now uses STARTTLS for encrypted transmission. * Improved authentication flow with LOGIN support and a fallback to PLAIN. * Address handling updated so sender/recipient replacements are applied consistently. * **Documentation** * Clarified notes on STARTTLS and authentication behavior.
1 parent a255fb2 commit 306bfa3

1 file changed

Lines changed: 168 additions & 9 deletions

File tree

internal/common/mailer/mailer.go

Lines changed: 168 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package mailer
33
import (
44
"bytes"
55
"context"
6+
"crypto/tls"
67
"encoding/base64"
78
"errors"
89
"fmt"
@@ -132,32 +133,190 @@ func (m *Client) sendWithAuth(
132133
subject, body string,
133134
attachments []string,
134135
) error {
136+
// Create a context with timeout for cancellation support
137+
ctx, cancel := context.WithTimeout(context.Background(), mailTimeout)
138+
defer cancel()
139+
135140
// Create a channel to receive the result
136141
type result struct {
137142
err error
138143
}
139144
resultChan := make(chan result, 1)
140145

141-
// Run SendMail in a goroutine
146+
// Run the mail sending in a goroutine with proper STARTTLS and auth support
142147
go func() {
143-
auth := smtp.PlainAuth("", m.username, m.password, m.host)
144-
body = processEmailBody(body)
145-
err := smtp.SendMail(
146-
m.host+":"+m.port, auth, from, to,
147-
m.composeMail(to, from, subject, body, attachments),
148-
)
148+
err := m.sendWithSTARTTLS(ctx, from, to, subject, body, attachments)
149149
resultChan <- result{err: err}
150150
}()
151151

152-
// Wait for either completion or timeout
152+
// Wait for either completion or context cancellation
153153
select {
154154
case res := <-resultChan:
155155
return res.err
156-
case <-time.After(mailTimeout):
156+
case <-ctx.Done():
157157
return fmt.Errorf("mail sending timeout after %v", mailTimeout)
158158
}
159159
}
160160

161+
// sendWithSTARTTLS connects to the SMTP server, negotiates STARTTLS if available,
162+
// and authenticates using LOGIN auth (falling back to PLAIN if LOGIN is unavailable).
163+
func (m *Client) sendWithSTARTTLS(
164+
ctx context.Context,
165+
from string,
166+
to []string,
167+
subject, body string,
168+
attachments []string,
169+
) error {
170+
addr := m.host + ":" + m.port
171+
172+
// Use a dialer with context for cancellation support
173+
dialer := &net.Dialer{
174+
Timeout: mailTimeout,
175+
}
176+
conn, err := dialer.DialContext(ctx, "tcp", addr)
177+
if err != nil {
178+
return fmt.Errorf("failed to connect to SMTP server: %w", err)
179+
}
180+
181+
// Set deadline based on context deadline for cleaner shutdown
182+
if deadline, ok := ctx.Deadline(); ok {
183+
if err := conn.SetDeadline(deadline); err != nil {
184+
_ = conn.Close()
185+
return err
186+
}
187+
}
188+
189+
// Create SMTP client
190+
c, err := smtp.NewClient(conn, m.host)
191+
if err != nil {
192+
_ = conn.Close()
193+
return fmt.Errorf("failed to create SMTP client: %w", err)
194+
}
195+
defer func() {
196+
_ = c.Close()
197+
}()
198+
199+
// Send EHLO/HELO
200+
if err = c.Hello("localhost"); err != nil {
201+
return fmt.Errorf("HELO failed: %w", err)
202+
}
203+
204+
// Check if STARTTLS is supported and upgrade connection
205+
if ok, _ := c.Extension("STARTTLS"); ok {
206+
tlsConfig := &tls.Config{
207+
ServerName: m.host,
208+
MinVersion: tls.VersionTLS12,
209+
}
210+
if err = c.StartTLS(tlsConfig); err != nil {
211+
return fmt.Errorf("STARTTLS failed: %w", err)
212+
}
213+
}
214+
215+
// Authenticate using LOGIN auth (more widely supported than PLAIN for "basic auth")
216+
if err = m.authenticate(ctx, c); err != nil {
217+
return fmt.Errorf("authentication failed: %w", err)
218+
}
219+
220+
// Set sender
221+
if err = c.Mail(replacer.Replace(from)); err != nil {
222+
return fmt.Errorf("MAIL FROM failed: %w", err)
223+
}
224+
225+
// Set recipients
226+
for i := range to {
227+
to[i] = replacer.Replace(to[i])
228+
if err = c.Rcpt(to[i]); err != nil {
229+
return fmt.Errorf("RCPT TO failed: %w", err)
230+
}
231+
}
232+
233+
// Send the email body
234+
wc, err := c.Data()
235+
if err != nil {
236+
return fmt.Errorf("DATA command failed: %w", err)
237+
}
238+
239+
body = processEmailBody(body)
240+
_, err = wc.Write(m.composeMail(to, from, subject, body, attachments))
241+
if err != nil {
242+
return fmt.Errorf("failed to write email body: %w", err)
243+
}
244+
245+
if err = wc.Close(); err != nil {
246+
return fmt.Errorf("failed to close data writer: %w", err)
247+
}
248+
249+
return c.Quit()
250+
}
251+
252+
// authenticate tries LOGIN auth first, then falls back to PLAIN auth.
253+
// LOGIN auth is more commonly supported for "basic authentication" scenarios.
254+
func (m *Client) authenticate(ctx context.Context, c *smtp.Client) error {
255+
// Check if server advertises AUTH extension
256+
if ok, _ := c.Extension("AUTH"); !ok {
257+
// Server doesn't advertise AUTH - this is unusual for servers requiring auth
258+
// but we'll let the mail commands fail naturally if auth was actually required
259+
logger.Debug(ctx, "SMTP server does not advertise AUTH extension",
260+
slog.String("host", m.host), slog.String("port", m.port))
261+
return nil
262+
}
263+
264+
// Try LOGIN auth first (more widely supported for "basic auth")
265+
loginAuth := &loginAuth{
266+
username: m.username,
267+
password: m.password,
268+
}
269+
loginErr := c.Auth(loginAuth)
270+
if loginErr == nil {
271+
return nil
272+
}
273+
274+
// Fall back to PLAIN auth if LOGIN fails
275+
plainAuth := smtp.PlainAuth("", m.username, m.password, m.host)
276+
plainErr := c.Auth(plainAuth)
277+
if plainErr == nil {
278+
return nil
279+
}
280+
281+
// Both failed - return a combined error message
282+
return fmt.Errorf("LOGIN auth failed: %v; PLAIN auth failed: %v", loginErr, plainErr)
283+
}
284+
285+
// loginAuth implements smtp.Auth interface for LOGIN authentication mechanism.
286+
// LOGIN auth is different from PLAIN auth - it sends username and password
287+
// in separate base64-encoded exchanges rather than combined.
288+
type loginAuth struct {
289+
username string
290+
password string
291+
}
292+
293+
func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) {
294+
// LOGIN auth can work over TLS or on localhost
295+
if !server.TLS {
296+
// Check for localhost
297+
if server.Name != "localhost" && server.Name != "127.0.0.1" && server.Name != "::1" {
298+
return "", nil, errors.New("LOGIN auth requires TLS connection")
299+
}
300+
}
301+
return "LOGIN", nil, nil
302+
}
303+
304+
func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
305+
if !more {
306+
return nil, nil
307+
}
308+
309+
prompt := strings.ToLower(string(fromServer))
310+
switch {
311+
case strings.Contains(prompt, "username"):
312+
return []byte(a.username), nil
313+
case strings.Contains(prompt, "password"):
314+
return []byte(a.password), nil
315+
default:
316+
return nil, fmt.Errorf("unexpected server prompt: %s", fromServer)
317+
}
318+
}
319+
161320
func (*Client) composeHeader(
162321
to []string, from string, subject string,
163322
) string {

0 commit comments

Comments
 (0)