@@ -3,6 +3,7 @@ package mailer
33import (
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+
161320func (* Client ) composeHeader (
162321 to []string , from string , subject string ,
163322) string {
0 commit comments