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
Changes from all commits
ef7ba37
fbff5e3
e849ef6
95eb6c4
6a4e837
5cd2d56
c945ef2
364774c
11a4552
04474b8
536c0a2
44ba38d
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,131 @@ 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 { | ||
| useImplicitTLS := port == 465 && strings.EqualFold(strings.TrimSpace(security), "SSL") | ||
| if useImplicitTLS { | ||
| conn, err := tls.Dial("tcp", addr, &tls.Config{ServerName: host}) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| defer conn.Close() | ||
| client, err := smtp.NewClient(conn, host) | ||
| if err != nil { | ||
| 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") | ||
| } | ||
| if ctx == nil { | ||
| ctx = context.Background() | ||
| } | ||
| ctx, cancel := context.WithTimeout(ctx, smtpSendTimeout) | ||
| defer cancel() | ||
|
|
||
| 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 := newSMTPAuth(cfg.Host, cfg.Username, cfg.Password) | ||
| msg := buildMessage(to, from, subject, body) | ||
| return sendMailWithConfig(ctx, addr, cfg.Host, cfg.Port, cfg.Security, auth, from, to, msg) | ||
| } | ||
|
|
||
| func newSMTPAuth(host, username, password string) smtp.Auth { | ||
| if username == "" { | ||
| return nil | ||
| } | ||
| return smtp.PlainAuth("", username, password, host) | ||
| } | ||
|
|
||
| // sendMailWithConfig sends email using the configured SMTP security mode: | ||
| // TLS requires STARTTLS, SSL uses implicit TLS, and None sends plaintext. | ||
| // Dialing honors ctx, and its deadline bounds every SMTP read and write. | ||
| func sendMailWithConfig(ctx context.Context, addr, host string, port int, security string, auth smtp.Auth, from, to string, msg []byte) error { | ||
| security = strings.ToUpper(strings.TrimSpace(security)) | ||
| if security == "" { | ||
| security = "TLS" | ||
| } | ||
| switch security { | ||
| case "TLS", "SSL", "NONE": | ||
| default: | ||
| return fmt.Errorf("unsupported SMTP security setting %q", security) | ||
| } | ||
|
|
||
| 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 | ||
| } | ||
| defer client.Close() | ||
| if err := client.Auth(auth); err != nil { | ||
| } | ||
| stopCancel := context.AfterFunc(ctx, func() { | ||
| _ = conn.SetDeadline(time.Now()) | ||
| }) | ||
| defer stopCancel() | ||
|
|
||
| var client *smtp.Client | ||
| if security == "SSL" { | ||
| tlsConn := tls.Client(conn, &tls.Config{ServerName: host}) | ||
| if err := tlsConn.HandshakeContext(ctx); err != nil { | ||
| return err | ||
| } | ||
| if err := client.Mail(from); err != nil { | ||
| if client, err = smtp.NewClient(tlsConn, host); err != nil { | ||
| return err | ||
| } | ||
| if err := client.Rcpt(to); err != nil { | ||
| } else { | ||
| if client, err = smtp.NewClient(conn, host); err != nil { | ||
| return err | ||
| } | ||
| w, err := client.Data() | ||
| if err != nil { | ||
| return err | ||
| } | ||
| defer client.Close() | ||
|
|
||
| switch security { | ||
| case "TLS": | ||
| if ok, _ := client.Extension("STARTTLS"); !ok { | ||
| return fmt.Errorf("SMTP server does not support STARTTLS") | ||
| } | ||
| if _, err := w.Write(msg); err != nil { | ||
| _ = w.Close() | ||
| if err := client.StartTLS(&tls.Config{ServerName: host}); err != nil { | ||
| return err | ||
| } | ||
| if err := w.Close(); err != nil { | ||
| case "NONE": | ||
| // The explicit None setting opts out of Go's opportunistic STARTTLS. | ||
| } | ||
|
|
||
| if err := sendMailWithClient(client, auth, from, to, msg); err != nil { | ||
| return err | ||
| } | ||
| return client.Quit() | ||
| } | ||
|
|
||
| func sendMailWithClient(client *smtp.Client, auth smtp.Auth, from, to string, msg []byte) error { | ||
| 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
+194
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 | ||
| } | ||
| w, err := client.Data() | ||
| if err != nil { | ||
| return err | ||
| } | ||
| if _, err := w.Write(msg); err != nil { | ||
| _ = w.Close() | ||
| return err | ||
| } | ||
| return w.Close() | ||
| } | ||
|
|
||
| // sanitizeHeader removes CR/LF to prevent header injection. | ||
|
|
||
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