diff --git a/apps/api/internal/handler/instance.go b/apps/api/internal/handler/instance.go index e4c1bd86..7ef7ef1b 100644 --- a/apps/api/internal/handler/instance.go +++ b/apps/api/internal/handler/instance.go @@ -7,12 +7,15 @@ import ( "encoding/json" "errors" "io" + "log/slog" "net/http" "net/url" + "strconv" "strings" "github.com/Devlaner/devlane/api/internal/auth" "github.com/Devlaner/devlane/api/internal/crypto" + "github.com/Devlaner/devlane/api/internal/mail" "github.com/Devlaner/devlane/api/internal/middleware" "github.com/Devlaner/devlane/api/internal/model" "github.com/Devlaner/devlane/api/internal/store" @@ -40,6 +43,7 @@ type InstanceSettingsHandler struct { Settings *store.InstanceSettingStore Admins *store.InstanceAdminStore Users *store.UserStore + Log *slog.Logger // OnSectionUpdated, if set, is invoked after a successful update with the // section key. Used for hot-reload of integration clients (e.g. github_app) // so the new credentials take effect without an API restart. @@ -617,3 +621,82 @@ func (h *InstanceSettingsHandler) UnsplashSearch(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{"results": results}) } + +type sendTestEmailRequest struct { + Host string `json:"host" binding:"required"` + Port string `json:"port" binding:"required"` + SenderEmail string `json:"sender_email" binding:"required,email"` + Security string `json:"security" binding:"required"` + Username string `json:"username"` + Password string `json:"password"` +} + +// SendTestEmail sends a test email using the SMTP values supplied by the +// instance-admin form. The values are not persisted by this endpoint. +// POST /api/instance/settings/email/test +func (h *InstanceSettingsHandler) SendTestEmail(c *gin.Context) { + if !h.requireInstanceAdmin(c) { + return + } + + var req sendTestEmailRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid email settings", "detail": err.Error()}) + return + } + + host := strings.TrimSpace(req.Host) + if host == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "SMTP host is required"}) + return + } + + port, err := strconv.Atoi(strings.TrimSpace(req.Port)) + if err != nil || port < 1 || port > 65535 { + c.JSON(http.StatusBadRequest, gin.H{"error": "SMTP port must be between 1 and 65535"}) + return + } + + security := strings.TrimSpace(req.Security) + switch security { + case "TLS", "SSL", "None": + default: + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid email security setting"}) + return + } + + user := middleware.GetUser(c) + if user == nil || user.Email == nil || strings.TrimSpace(*user.Email) == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Your account does not have an email address"}) + return + } + + recipient := strings.TrimSpace(*user.Email) + cfg := &mail.SMTPSettings{ + Host: host, + Port: port, + SenderEmail: strings.TrimSpace(req.SenderEmail), + Security: security, + Username: strings.TrimSpace(req.Username), + Password: req.Password, + } + + if err := mail.SendWithSMTPSettings( + c.Request.Context(), + cfg, + recipient, + "Devlane SMTP test email", + "This is a test email from Devlane. Your SMTP settings are working.", + h.Log, + ); err != nil { + if h.Log != nil { + h.Log.Error("send SMTP test email", "error", err, "recipient", recipient) + } + c.JSON(http.StatusBadGateway, gin.H{ + "error": "Failed to send test email. Check the SMTP settings and server logs.", + }) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Test email sent"}) +} diff --git a/apps/api/internal/mail/mail.go b/apps/api/internal/mail/mail.go index 7d2bfbb4..8a44c72b 100644 --- a/apps/api/internal/mail/mail.go +++ b/apps/api/internal/mail/mail.go @@ -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 + } + 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. diff --git a/apps/api/internal/mail/mail_timeout_test.go b/apps/api/internal/mail/mail_timeout_test.go new file mode 100644 index 00000000..53b9a314 --- /dev/null +++ b/apps/api/internal/mail/mail_timeout_test.go @@ -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) + } +} diff --git a/apps/api/internal/router/router.go b/apps/api/internal/router/router.go index ad7620a7..39bd66de 100644 --- a/apps/api/internal/router/router.go +++ b/apps/api/internal/router/router.go @@ -140,7 +140,7 @@ func New(cfg Config) (*gin.Engine, *service.ImporterService) { r.GET("/api/invitations/by-token/", invitationHandler.GetInviteByToken) r.POST("/api/invitations/decline/", invitationHandler.DeclineInviteByToken) - instanceSettingsHandler := &handler.InstanceSettingsHandler{Settings: instanceSettingStore, Admins: instanceAdminStore, Users: userStore} + instanceSettingsHandler := &handler.InstanceSettingsHandler{Settings: instanceSettingStore, Admins: instanceAdminStore, Users: userStore, Log: cfg.Log} // Services workspaceSvc := service.NewWorkspaceService(workspaceStore, workspaceInviteStore, userStore) @@ -313,6 +313,7 @@ func New(cfg Config) (*gin.Engine, *service.ImporterService) { api.DELETE("/workspaces/:slug/favorites/:favId/", favoriteHandler.DeleteFavorite) api.GET("/instance/settings/", instanceSettingsHandler.GetSettings) api.PATCH("/instance/settings/:key", instanceSettingsHandler.UpdateSetting) + api.POST("/instance/settings/email/test", instanceSettingsHandler.SendTestEmail) api.GET("/instance/unsplash/search", instanceSettingsHandler.UnsplashSearch) // Instance-admin management (admin-gated inside the handler). api.GET("/instance/admins/", instanceSettingsHandler.ListAdmins) diff --git a/apps/api/migrations/000013_slack_integration.down.sql b/apps/api/migrations/000014_slack_integration.down.sql similarity index 100% rename from apps/api/migrations/000013_slack_integration.down.sql rename to apps/api/migrations/000014_slack_integration.down.sql diff --git a/apps/api/migrations/000013_slack_integration.up.sql b/apps/api/migrations/000014_slack_integration.up.sql similarity index 100% rename from apps/api/migrations/000013_slack_integration.up.sql rename to apps/api/migrations/000014_slack_integration.up.sql diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts index 72ee42ca..54062f3a 100644 --- a/apps/web/src/api/types.ts +++ b/apps/web/src/api/types.ts @@ -556,6 +556,16 @@ export interface InstanceEmailSection { password?: string; } +/** SMTP settings used only to send a test email; they are not persisted. */ +export interface InstanceEmailTestRequest { + host: string; + port: string; + sender_email: string; + security: string; + username: string; + password: string; +} + /** Auth section shape */ export interface InstanceAuthSection { allow_public_signup?: boolean; diff --git a/apps/web/src/i18n/locales/en/translation.json b/apps/web/src/i18n/locales/en/translation.json index 2f089869..8ac09139 100644 --- a/apps/web/src/i18n/locales/en/translation.json +++ b/apps/web/src/i18n/locales/en/translation.json @@ -877,10 +877,12 @@ "instanceAdmin.email.securityNone": "None", "instanceAdmin.email.senderEmail": "Sender's email address", "instanceAdmin.email.senderEmailHint": "This is the email address your users will see when getting emails from this instance. You will need to verify this address.", + "instanceAdmin.email.sendingTest": "Sending test email…", "instanceAdmin.email.sendTest": "Send test email", "instanceAdmin.email.setPassword": "Set password", "instanceAdmin.email.showPassword": "Show password", "instanceAdmin.email.subtitle": "Devlane can send useful emails to you and your users from your own instance without talking to the Internet. Set it up below and please test your settings before you save them.", + "instanceAdmin.email.testSuccess": "Test email sent to your account email.", "instanceAdmin.email.title": "Secure emails from your own instance", "instanceAdmin.email.username": "Username", "instanceAdmin.general.documentTitle": "General settings", diff --git a/apps/web/src/pages/instance-admin/InstanceAdminEmailPage.tsx b/apps/web/src/pages/instance-admin/InstanceAdminEmailPage.tsx index 38c9f900..f5fdffe5 100644 --- a/apps/web/src/pages/instance-admin/InstanceAdminEmailPage.tsx +++ b/apps/web/src/pages/instance-admin/InstanceAdminEmailPage.tsx @@ -1,8 +1,8 @@ import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Button, IconEye, IconEyeOff, Skeleton } from '../../components/ui'; -import { instanceSettingsService } from '../../services/instanceService'; -import { getApiErrorMessage } from '../../api/client'; +import { instanceSettingsService } from '../../services'; +import { getApiErrorMessage } from '../../api'; import { useDocumentTitle } from '../../hooks/useDocumentTitle'; import type { InstanceEmailSection } from '../../api/types'; @@ -21,11 +21,25 @@ export function InstanceAdminEmailPage() { const [showSmtpPassword, setShowSmtpPassword] = useState(false); const [smtpPasswordLocal, setSmtpPasswordLocal] = useState(undefined); // undefined = show stored mask, '' = user cleared, string = user typed const [error, setError] = useState(''); + const [testing, setTesting] = useState(false); + const [testSuccess, setTestSuccess] = useState(''); useDocumentTitle(t('instanceAdmin.email.documentTitle', 'Email')); const smtpPasswordDisplay = smtpPasswordLocal !== undefined ? smtpPasswordLocal : (email.password ?? ''); + const updateEmail = (changes: Partial) => { + setEmail((previous) => ({ ...previous, ...changes })); + setTestSuccess(''); + }; + + const portText = (email.port ?? '').trim(); + const smtpPort = Number(portText); + const hasValidPort = + /^\d+$/.test(portText) && Number.isInteger(smtpPort) && smtpPort >= 1 && smtpPort <= 65535; + const hasValidSender = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test((email.sender_email ?? '').trim()); + const canSendTest = + !saving && !testing && (email.host ?? '').trim() !== '' && hasValidPort && hasValidSender; useEffect(() => { let cancelled = false; instanceSettingsService @@ -72,6 +86,31 @@ export function InstanceAdminEmailPage() { .finally(() => setSaving(false)); }; + const handleSendTest = () => { + setError(''); + setTestSuccess(''); + setTesting(true); + + const passwordToSend = smtpPasswordLocal !== undefined ? smtpPasswordLocal : email.password; + + instanceSettingsService + .sendTestEmail({ + host: (email.host ?? '').trim(), + port: (email.port ?? '').trim(), + sender_email: (email.sender_email ?? '').trim(), + security: email.security ?? 'TLS', + username: (email.username ?? '').trim(), + password: passwordToSend ?? '', + }) + .then(() => { + setTestSuccess( + t('instanceAdmin.email.testSuccess', 'Test email sent to your account email.'), + ); + }) + .catch((err) => setError(getApiErrorMessage(err))) + .finally(() => setTesting(false)); + }; + if (loading) { return (
@@ -139,7 +178,17 @@ export function InstanceAdminEmailPage() {

- {error &&

{error}

} + {error && ( +

+ {error} +

+ )} + + {testSuccess && ( +

+ {testSuccess} +

+ )}
@@ -148,7 +197,7 @@ export function InstanceAdminEmailPage() { setEmail((p) => ({ ...p, host: e.target.value }))} + onChange={(e) => updateEmail({ host: e.target.value })} className="mt-0.5 block w-full rounded border border-(--border-subtle) bg-(--bg-surface-1) px-2.5 py-1.5 text-xs text-(--txt-primary) focus:outline-none" /> @@ -157,7 +206,7 @@ export function InstanceAdminEmailPage() { setEmail((p) => ({ ...p, port: e.target.value }))} + onChange={(e) => updateEmail({ port: e.target.value })} className="mt-0.5 block w-full rounded border border-(--border-subtle) bg-(--bg-surface-1) px-2.5 py-1.5 text-xs text-(--txt-primary) focus:outline-none" /> @@ -167,7 +216,7 @@ export function InstanceAdminEmailPage() { setEmail((p) => ({ ...p, sender_email: e.target.value }))} + onChange={(e) => updateEmail({ sender_email: e.target.value })} className="mt-0.5 block w-full rounded border border-(--border-subtle) bg-(--bg-surface-1) px-2.5 py-1.5 text-xs text-(--txt-primary) focus:outline-none" />

@@ -181,7 +230,7 @@ export function InstanceAdminEmailPage() { {t('instanceAdmin.email.security', 'Email security')} setEmail((p) => ({ ...p, username: e.target.value }))} + onChange={(e) => updateEmail({ username: e.target.value })} className="mt-0.5 block w-full rounded border border-(--border-subtle) bg-(--bg-surface-1) px-2.5 py-1.5 text-xs text-(--txt-primary) focus:outline-none" /> @@ -218,7 +267,10 @@ export function InstanceAdminEmailPage() { setSmtpPasswordLocal(e.target.value)} + onChange={(e) => { + setSmtpPasswordLocal(e.target.value); + setTestSuccess(''); + }} onFocus={() => { // Only copy the loaded password into local edit state when it is non-empty. // Otherwise we would set local state to "" and the next save would send an empty @@ -252,11 +304,20 @@ export function InstanceAdminEmailPage() {

- -
diff --git a/apps/web/src/services/instanceService.ts b/apps/web/src/services/instanceService.ts index 9fe1e97c..b522c00c 100644 --- a/apps/web/src/services/instanceService.ts +++ b/apps/web/src/services/instanceService.ts @@ -1,4 +1,4 @@ -import { apiClient } from '../api/client'; +import { apiClient } from '../api'; import type { InstanceSetupStatusResponse, InstanceSetupRequest, @@ -6,6 +6,7 @@ import type { InstanceSettingsResponse, InstanceSettingSectionValue, InstanceAdminApiResponse, + InstanceEmailTestRequest, } from '../api/types'; /** @@ -50,6 +51,14 @@ export const instanceSettingsService = { return data; }, + async sendTestEmail(payload: InstanceEmailTestRequest): Promise<{ message: string }> { + const { data } = await apiClient.post<{ message: string }>( + '/api/instance/settings/email/test', + payload, + ); + return data; + }, + /** Search Unsplash (uses instance image API key). GET /api/instance/unsplash/search?q= */ async unsplashSearch(q: string): Promise<{ results: UnsplashSearchResult[] }> { const { data } = await apiClient.get<{ results: UnsplashSearchResult[] }>(