diff --git a/cmd/handlers.go b/cmd/handlers.go index c75b3c1e..2759c41c 100644 --- a/cmd/handlers.go +++ b/cmd/handlers.go @@ -35,6 +35,7 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) { g.PUT("/api/v1/settings/general", perm(handleUpdateGeneralSettings, "general_settings:manage")) g.GET("/api/v1/settings/notifications/email", perm(handleGetEmailNotificationSettings, "notification_settings:manage")) g.PUT("/api/v1/settings/notifications/email", perm(handleUpdateEmailNotificationSettings, "notification_settings:manage")) + g.POST("/api/v1/settings/notifications/email/test", perm(handleTestEmailNotificationSettings, "notification_settings:manage")) // OpenID connect single sign-on. g.GET("/api/v1/oidc", perm(handleGetAllOIDC, "oidc:manage")) diff --git a/cmd/settings.go b/cmd/settings.go index 33d7b7e0..aa6f69ce 100644 --- a/cmd/settings.go +++ b/cmd/settings.go @@ -1,9 +1,13 @@ package main import ( + "crypto/tls" "encoding/json" + "fmt" "net/mail" + "net/smtp" "strings" + "time" "github.com/abhinavxd/libredesk/internal/envelope" "github.com/abhinavxd/libredesk/internal/setting/models" @@ -143,3 +147,208 @@ func handleUpdateEmailNotificationSettings(r *fastglue.Request) error { return r.SendEnvelope(true) } + +// TestEmailRequest represents the request body for testing email settings. +type TestEmailRequest struct { + models.EmailNotification + TestEmail string `json:"test_email"` +} + +// TestEmailResponse represents the response for testing email settings. +type TestEmailResponse struct { + Success bool `json:"success"` + Logs []string `json:"logs"` +} + +// handleTestEmailNotificationSettings tests the email notification settings by sending a test email. +func handleTestEmailNotificationSettings(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + req = TestEmailRequest{} + cur = models.EmailNotification{} + logs = []string{} + ) + + addLog := func(msg string) { + logs = append(logs, fmt.Sprintf("[%s] %s", time.Now().Format("15:04:05"), msg)) + } + + if err := r.Decode(&req, "json"); err != nil { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.badRequest"), nil, envelope.InputError) + } + + // Validate test email + if req.TestEmail == "" { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Test email address is required", nil, envelope.InputError) + } + if _, err := mail.ParseAddress(req.TestEmail); err != nil { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid test email address", nil, envelope.InputError) + } + + addLog(fmt.Sprintf("Starting SMTP test to %s", req.TestEmail)) + + // Get current settings to fill in password if not provided + out, err := app.setting.GetByPrefix("notification.email") + if err != nil { + addLog(fmt.Sprintf("Error fetching current settings: %v", err)) + return r.SendEnvelope(TestEmailResponse{Success: false, Logs: logs}) + } + if err := json.Unmarshal(out, &cur); err != nil { + addLog(fmt.Sprintf("Error parsing current settings: %v", err)) + return r.SendEnvelope(TestEmailResponse{Success: false, Logs: logs}) + } + + // Use current password if not provided in request + password := req.Password + if password == "" || strings.Contains(password, stringutil.PasswordDummy) { + password = cur.Password + } + + // Build server address + serverAddr := fmt.Sprintf("%s:%d", req.Host, req.Port) + addLog(fmt.Sprintf("Connecting to SMTP server: %s", serverAddr)) + + // Create TLS config + tlsConfig := &tls.Config{ + ServerName: req.Host, + InsecureSkipVerify: req.TLSSkipVerify, + } + + var client *smtp.Client + + // Connect based on TLS type + switch req.TLSType { + case "tls": + addLog("Using SSL/TLS connection") + conn, err := tls.Dial("tcp", serverAddr, tlsConfig) + if err != nil { + addLog(fmt.Sprintf("TLS connection failed: %v", err)) + return r.SendEnvelope(TestEmailResponse{Success: false, Logs: logs}) + } + defer conn.Close() + client, err = smtp.NewClient(conn, req.Host) + if err != nil { + addLog(fmt.Sprintf("Failed to create SMTP client: %v", err)) + return r.SendEnvelope(TestEmailResponse{Success: false, Logs: logs}) + } + default: + addLog("Using plain connection") + var err error + client, err = smtp.Dial(serverAddr) + if err != nil { + addLog(fmt.Sprintf("Connection failed: %v", err)) + return r.SendEnvelope(TestEmailResponse{Success: false, Logs: logs}) + } + } + defer client.Close() + + // Send HELO/EHLO + hostname := req.HelloHostname + if hostname == "" { + hostname = "localhost" + } + addLog(fmt.Sprintf("Sending EHLO %s", hostname)) + if err := client.Hello(hostname); err != nil { + addLog(fmt.Sprintf("EHLO failed: %v", err)) + return r.SendEnvelope(TestEmailResponse{Success: false, Logs: logs}) + } + + // STARTTLS if required + if req.TLSType == "starttls" { + addLog("Starting TLS (STARTTLS)") + if err := client.StartTLS(tlsConfig); err != nil { + addLog(fmt.Sprintf("STARTTLS failed: %v", err)) + return r.SendEnvelope(TestEmailResponse{Success: false, Logs: logs}) + } + addLog("TLS connection established") + } + + // Authenticate if credentials provided + if req.Username != "" && password != "" { + addLog(fmt.Sprintf("Authenticating as %s using %s", req.Username, req.AuthProtocol)) + var auth smtp.Auth + switch req.AuthProtocol { + case "plain": + auth = smtp.PlainAuth("", req.Username, password, req.Host) + case "login": + auth = &loginAuth{username: req.Username, password: password} + case "cram": + auth = smtp.CRAMMD5Auth(req.Username, password) + case "none": + addLog("No authentication required") + default: + auth = smtp.PlainAuth("", req.Username, password, req.Host) + } + if auth != nil { + if err := client.Auth(auth); err != nil { + addLog(fmt.Sprintf("Authentication failed: %v", err)) + return r.SendEnvelope(TestEmailResponse{Success: false, Logs: logs}) + } + addLog("Authentication successful") + } + } + + // Set sender + fromAddr := req.EmailAddress + if fromAddr == "" { + fromAddr = req.Username + } + addLog(fmt.Sprintf("Setting sender: %s", fromAddr)) + if err := client.Mail(fromAddr); err != nil { + addLog(fmt.Sprintf("MAIL FROM failed: %v", err)) + return r.SendEnvelope(TestEmailResponse{Success: false, Logs: logs}) + } + + // Set recipient + addLog(fmt.Sprintf("Setting recipient: %s", req.TestEmail)) + if err := client.Rcpt(req.TestEmail); err != nil { + addLog(fmt.Sprintf("RCPT TO failed: %v", err)) + return r.SendEnvelope(TestEmailResponse{Success: false, Logs: logs}) + } + + // Send test message + addLog("Sending test message") + w, err := client.Data() + if err != nil { + addLog(fmt.Sprintf("DATA command failed: %v", err)) + return r.SendEnvelope(TestEmailResponse{Success: false, Logs: logs}) + } + + msg := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: LibreDesk SMTP Test\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\nThis is a test email from LibreDesk to verify your SMTP notification settings are working correctly.\r\n\r\nSent at: %s", + fromAddr, req.TestEmail, time.Now().Format(time.RFC1123)) + + if _, err := w.Write([]byte(msg)); err != nil { + addLog(fmt.Sprintf("Failed to write message: %v", err)) + return r.SendEnvelope(TestEmailResponse{Success: false, Logs: logs}) + } + if err := w.Close(); err != nil { + addLog(fmt.Sprintf("Failed to close message: %v", err)) + return r.SendEnvelope(TestEmailResponse{Success: false, Logs: logs}) + } + + addLog("Test email sent successfully!") + client.Quit() + + return r.SendEnvelope(TestEmailResponse{Success: true, Logs: logs}) +} + +// loginAuth implements smtp.Auth for LOGIN authentication +type loginAuth struct { + username, password string +} + +func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) { + return "LOGIN", []byte{}, nil +} + +func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) { + if more { + switch string(fromServer) { + case "Username:": + return []byte(a.username), nil + case "Password:": + return []byte(a.password), nil + } + } + return nil, nil +} diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 8c3b6b25..0a3f9995 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -61,6 +61,8 @@ const searchContacts = (params) => http.get('/api/v1/contacts/search', { params const getEmailNotificationSettings = () => http.get('/api/v1/settings/notifications/email') const updateEmailNotificationSettings = (data) => http.put('/api/v1/settings/notifications/email', data) +const testEmailNotificationSettings = (data) => + http.post("/api/v1/settings/notifications/email/test", data) const getPriorities = () => http.get('/api/v1/priorities') const getStatuses = () => http.get('/api/v1/statuses') const createStatus = (data) => http.post('/api/v1/statuses', data) @@ -536,6 +538,7 @@ export default { getUsersCompact, getEmailNotificationSettings, updateEmailNotificationSettings, + testEmailNotificationSettings, getCurrentUserViews, createView, updateView, diff --git a/frontend/src/features/admin/notification/NotificationSettingForm.vue b/frontend/src/features/admin/notification/NotificationSettingForm.vue index 440e5cf5..0e717a1d 100644 --- a/frontend/src/features/admin/notification/NotificationSettingForm.vue +++ b/frontend/src/features/admin/notification/NotificationSettingForm.vue @@ -197,7 +197,58 @@ - +
+ +
+ + +
+

Test Connection

+
+
+ +
+ + +
+
+ + +
+ +
+
+ {{ log }} +
+
+
+ + Test successful! Check your inbox. +
+
+ + Test failed. Check the log above for details. +
+
+
+
@@ -228,8 +279,14 @@ import { Switch } from '@/components/ui/switch' import { Label } from '@/components/ui/label' import { Input } from '@/components/ui/input' import { useI18n } from 'vue-i18n' +import { Loader2, CheckCircle, XCircle } from 'lucide-vue-next' +import api from '@/api' const isLoading = ref(false) +const isTesting = ref(false) +const testEmail = ref('') +const testLogs = ref([]) +const testSuccess = ref(null) const { t } = useI18n() const props = defineProps({ initialValues: { @@ -275,4 +332,34 @@ watch( }, { deep: true, immediate: true } ) + +// Run SMTP test +const runTest = async () => { + isTesting.value = true + testLogs.value = [] + testSuccess.value = null + + try { + const values = smtpForm.values + const response = await api.testEmailNotificationSettings({ + 'notification.email.host': values.host, + 'notification.email.port': values.port, + 'notification.email.username': values.username, + 'notification.email.password': values.password, + 'notification.email.auth_protocol': values.auth_protocol, + 'notification.email.tls_type': values.tls_type, + 'notification.email.tls_skip_verify': values.tls_skip_verify, + 'notification.email.email_address': values.email_address, + 'notification.email.hello_hostname': values.hello_hostname, + test_email: testEmail.value + }) + testLogs.value = response.data.data.logs || [] + testSuccess.value = response.data.data.success + } catch (error) { + testLogs.value = [`Error: ${error.response?.data?.message || error.message}`] + testSuccess.value = false + } finally { + isTesting.value = false + } +}