Repository navigation
fix(instance-admin): enable SMTP test emails #405
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
ef7ba37
fbff5e3
e849ef6
95eb6c4
6a4e837
5cd2d56
c945ef2
364774c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,15 +5,17 @@ import ( | |
| "crypto/tls" | ||
| "fmt" | ||
| "log/slog" | ||
| "net" | ||
| "net/smtp" | ||
| "strconv" | ||
| "strings" | ||
| "time" | ||
|
|
||
| "github.com/Devlaner/devlane/api/internal/crypto" | ||
| "github.com/Devlaner/devlane/api/internal/store" | ||
| ) | ||
|
|
||
| type smtpSettings struct { | ||
| type SMTPSettings struct { | ||
| Host string | ||
| Port int | ||
| SenderEmail string | ||
|
|
@@ -22,7 +24,7 @@ type smtpSettings struct { | |
| Password string | ||
| } | ||
|
|
||
| func getEmailSettings(ctx context.Context, s *store.InstanceSettingStore) (*smtpSettings, error) { | ||
| func getEmailSettings(ctx context.Context, s *store.InstanceSettingStore) (*SMTPSettings, error) { | ||
| row, err := s.Get(ctx, "email") | ||
| if err != nil || row == nil { | ||
| return nil, fmt.Errorf("email settings not found") | ||
|
|
@@ -55,7 +57,7 @@ func getEmailSettings(ctx context.Context, s *store.InstanceSettingStore) (*smtp | |
| if host == "" { | ||
| return nil, fmt.Errorf("email host not configured") | ||
| } | ||
| return &smtpSettings{ | ||
| return &SMTPSettings{ | ||
| Host: host, | ||
| Port: port, | ||
| SenderEmail: strings.TrimSpace(sender), | ||
|
|
@@ -78,63 +80,129 @@ func NewSMTPEmailSender(instanceSettings *store.InstanceSettingStore, log *slog. | |
| LogSkip(log, "instance email not configured", to, err) | ||
| return err | ||
| } | ||
| from := cfg.SenderEmail | ||
| if from == "" { | ||
| from = cfg.Username | ||
| } | ||
| if from == "" { | ||
| LogSkip(log, "sender_email and username empty", to, fmt.Errorf("sender not set")) | ||
| return fmt.Errorf("sender email not configured") | ||
| } | ||
| addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port) | ||
| auth := smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host) | ||
| msg := buildMessage(to, from, subject, body) | ||
| if err := sendMailWithConfig(addr, cfg.Host, cfg.Port, cfg.Security, auth, from, to, msg); err != nil { | ||
| if err := SendWithSMTPSettings(ctx, cfg, to, subject, body, log); err != nil { | ||
| return err | ||
| } | ||
| return nil | ||
| } | ||
| } | ||
|
|
||
| // sendMailWithConfig sends email using smtp.SendMail or, for port 465 with SSL, | ||
| // an explicit TLS connection (smtp.SendMail only supports STARTTLS). | ||
| func sendMailWithConfig(addr, host string, port int, security string, auth smtp.Auth, from, to string, msg []byte) error { | ||
| var smtpSendTimeout = 15 * time.Second | ||
|
|
||
| // SendWithSMTPSettings sends an email using the supplied SMTP settings without persisting them. | ||
| func SendWithSMTPSettings(ctx context.Context, cfg *SMTPSettings, to, subject, body string, log *slog.Logger) error { | ||
| if cfg == nil { | ||
| return fmt.Errorf("SMTP settings not configured") | ||
| } | ||
| from := cfg.SenderEmail | ||
|
|
||
| if ctx == nil { | ||
| ctx = context.Background() | ||
| } | ||
| ctx, cancel := context.WithTimeout(ctx, smtpSendTimeout) | ||
| defer cancel() | ||
|
|
||
| if from == "" { | ||
| from = cfg.Username | ||
| } | ||
| if from == "" { | ||
| LogSkip(log, "sender_email and username empty", to, fmt.Errorf("sender not set")) | ||
| return fmt.Errorf("sender email not configured") | ||
| } | ||
| addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port) | ||
| var auth smtp.Auth | ||
| if cfg.Username != "" || cfg.Password != "" { | ||
| auth = smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host) | ||
| } | ||
| msg := buildMessage(to, from, subject, body) | ||
| if err := sendMailWithConfig(ctx, addr, cfg.Host, cfg.Port, cfg.Security, auth, from, to, msg); err != nil { | ||
| return err | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| // sendMailWithConfig delivers an email over SMTP using context-aware dialing | ||
| // and connection deadlines to bound SMTP read and write operations. | ||
| func sendMailWithConfig( | ||
| ctx context.Context, | ||
| addr, host string, | ||
| port int, | ||
| security string, | ||
| auth smtp.Auth, | ||
| from, to string, | ||
| msg []byte, | ||
| ) error { | ||
| conn, err := (&net.Dialer{}).DialContext(ctx, "tcp", addr) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| defer conn.Close() | ||
|
|
||
| if deadline, ok := ctx.Deadline(); ok { | ||
| if err := conn.SetDeadline(deadline); err != nil { | ||
| return err | ||
| } | ||
| } | ||
|
|
||
| stopCancel := context.AfterFunc(ctx, func() { | ||
| _ = conn.SetDeadline(time.Now()) | ||
| }) | ||
| defer stopCancel() | ||
|
|
||
| var client *smtp.Client | ||
| useImplicitTLS := port == 465 && strings.EqualFold(strings.TrimSpace(security), "SSL") | ||
|
|
||
| if useImplicitTLS { | ||
| conn, err := tls.Dial("tcp", addr, &tls.Config{ServerName: host}) | ||
| if err != nil { | ||
| tlsConn := tls.Client(conn, &tls.Config{ServerName: host}) | ||
| if err := tlsConn.HandshakeContext(ctx); err != nil { | ||
| return err | ||
| } | ||
| defer conn.Close() | ||
| client, err := smtp.NewClient(conn, host) | ||
|
|
||
| client, err = smtp.NewClient(tlsConn, host) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| defer client.Close() | ||
| if err := client.Auth(auth); err != nil { | ||
| return err | ||
| } | ||
| if err := client.Mail(from); err != nil { | ||
| return err | ||
| } | ||
| if err := client.Rcpt(to); err != nil { | ||
| return err | ||
| } | ||
| w, err := client.Data() | ||
| } else { | ||
| client, err = smtp.NewClient(conn, host) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| if _, err := w.Write(msg); err != nil { | ||
| _ = w.Close() | ||
| return err | ||
|
|
||
| // Preserve smtp.SendMail's existing behavior: use STARTTLS when the | ||
| // server advertises it. | ||
| if ok, _ := client.Extension("STARTTLS"); ok { | ||
| if err := client.StartTLS(&tls.Config{ServerName: host}); err != nil { | ||
| return err | ||
| } | ||
| } | ||
| if err := w.Close(); err != nil { | ||
| } | ||
| defer client.Close() | ||
|
|
||
| if auth != nil { | ||
| if err := client.Auth(auth); err != nil { | ||
| return err | ||
| } | ||
| return client.Quit() | ||
| } | ||
| // STARTTLS (port 587) or no security: standard SendMail | ||
| return smtp.SendMail(addr, auth, from, []string{to}, msg) | ||
| if err := client.Mail(from); err != nil { | ||
| return err | ||
|
Comment on lines
+180
to
+187
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- handler security validation and call ---'
rg -n -C 8 'Security|SendWithSMTPSettings' apps/api/internal/handler/instance.go
printf '%s\n' '--- SMTP sender implementation ---'
rg -n -C 12 'func sendMailWithConfig|StartTLS|NewClient|Dial|client\.Mail|client\.Auth' apps/api/internal/mail/mail.goRepository: Devlaner/devlane Length of output: 4354 Security Misconfiguration Reachability: External Reject TLS-mode servers that do not advertise STARTTLS. The test-email endpoint accepts Proposed fix if ok, _ := client.Extension("STARTTLS"); ok {
if err := client.StartTLS(&tls.Config{ServerName: host}); err != nil {
return err
}
+ } else if strings.EqualFold(strings.TrimSpace(security), "TLS") {
+ return fmt.Errorf("SMTP server does not support STARTTLS")
}🤖 Prompt for AI Agents |
||
| } | ||
| if err := client.Rcpt(to); err != nil { | ||
| return err | ||
| } | ||
|
|
||
| writer, err := client.Data() | ||
| if err != nil { | ||
| return err | ||
| } | ||
| if _, err := writer.Write(msg); err != nil { | ||
| _ = writer.Close() | ||
| return err | ||
| } | ||
| if err := writer.Close(); err != nil { | ||
| return err | ||
| } | ||
|
|
||
| return client.Quit() | ||
| } | ||
|
|
||
| // sanitizeHeader removes CR/LF to prevent header injection. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,64 @@ | ||
| package mail | ||
|
|
||
| import ( | ||
| "context" | ||
| "net" | ||
| "testing" | ||
| "time" | ||
| ) | ||
|
|
||
| func TestSendWithSMTPSettings_TimesOutWhenSMTPServerStalls(t *testing.T) { | ||
| listener, err := net.Listen("tcp", "127.0.0.1:0") | ||
| if err != nil { | ||
| t.Fatalf("listen: %v", err) | ||
| } | ||
| t.Cleanup(func() { | ||
| _ = listener.Close() | ||
| }) | ||
|
|
||
| releaseConnection := make(chan struct{}) | ||
| t.Cleanup(func() { | ||
| close(releaseConnection) | ||
| }) | ||
|
|
||
| // Accept the TCP connection but deliberately never send the SMTP greeting. | ||
| go func() { | ||
| conn, err := listener.Accept() | ||
| if err != nil { | ||
| return | ||
| } | ||
| defer conn.Close() | ||
| <-releaseConnection | ||
| }() | ||
|
|
||
| previousTimeout := smtpSendTimeout | ||
| smtpSendTimeout = 100 * time.Millisecond | ||
| t.Cleanup(func() { | ||
| smtpSendTimeout = previousTimeout | ||
| }) | ||
|
|
||
| port := listener.Addr().(*net.TCPAddr).Port | ||
| started := time.Now() | ||
|
|
||
| err = SendWithSMTPSettings( | ||
| context.Background(), | ||
| &SMTPSettings{ | ||
| Host: "127.0.0.1", | ||
| Port: port, | ||
| SenderEmail: "sender@example.test", | ||
| Security: "None", | ||
| }, | ||
| "admin@example.test", | ||
| "Test subject", | ||
| "Test body", | ||
| nil, | ||
| ) | ||
|
|
||
| if err == nil { | ||
| t.Fatal("expected SMTP send to time out") | ||
| } | ||
|
|
||
| if elapsed := time.Since(started); elapsed > time.Second { | ||
| t.Fatalf("SMTP timeout took too long: %s", elapsed) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift
Route SMTP test delivery through an application service.
The
apps/api/guidance defines the layering rule as “handler → service → store” and describes handlers as HTTP-shape code that binds requests, calls services, and returns JSON.SendTestEmailinstead performs SMTP delivery by callingmail.SendWithSMTPSettingsdirectly. Move this operation into an instance email service, then call that service from the handler.🤖 Prompt for AI Agents