From 05c48b1b384d1db4335217e796528f5f2070db20 Mon Sep 17 00:00:00 2001 From: Sentinent Agent Date: Wed, 29 Apr 2026 04:20:08 +0000 Subject: [PATCH 1/4] feat(invitations): send invitation email on CreateInvitation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a workspace owner sends an invitation, the backend now emails the recipient with: - the inviter's email address - the workspace name - a direct accept link (FRONTEND_BASE_URL/invitations/) - a reminder that the invitation expires in 7 days Implementation: - services/mailer.go: add SendInvitationEmail() — same SMTP path as SendPasswordResetEmail, reuses LoadSMTPConfigFromEnv() - handlers/invitations.go: fire SendInvitationEmail() in a goroutine after the DB insert; failures are logged but do not block the 201 response so the invitation record is always saved SMTP must be configured via SMTP_HOST / SMTP_PORT / SMTP_FROM_EMAIL (+ optionally SMTP_USERNAME, SMTP_PASSWORD, SMTP_FROM_NAME). If SMTP is not configured the email is skipped and a log line is printed — existing behaviour is unchanged. --- handlers/invitations.go | 29 +++++++++++++++++++++++++++++ services/mailer.go | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/handlers/invitations.go b/handlers/invitations.go index 8481e29..7573c5a 100644 --- a/handlers/invitations.go +++ b/handlers/invitations.go @@ -5,10 +5,14 @@ import ( "database/sql" "encoding/hex" "encoding/json" + "fmt" + "log" "net/http" + "os" "sentinent-backend/database" "sentinent-backend/middleware" "sentinent-backend/models" + "sentinent-backend/services" "sentinent-backend/utils" "strconv" "strings" @@ -113,6 +117,31 @@ func CreateInvitation(w http.ResponseWriter, r *http.Request) { } invitationID, _ := result.LastInsertId() + + // Send invitation email — look up workspace name and inviter email for the message body. + go func() { + var workspaceName string + if err := database.DB.QueryRow("SELECT name FROM workspaces WHERE id = ?", workspaceID).Scan(&workspaceName); err != nil { + log.Printf("invitation email: could not fetch workspace name for workspace %d: %v", workspaceID, err) + return + } + var inviterEmail string + if err := database.DB.QueryRow("SELECT email FROM users WHERE id = ?", userID).Scan(&inviterEmail); err != nil { + log.Printf("invitation email: could not fetch inviter email for user %d: %v", userID, err) + return + } + baseURL := strings.TrimRight(strings.TrimSpace(os.Getenv("FRONTEND_BASE_URL")), "/") + if baseURL == "" { + baseURL = "http://localhost:4200" + } + acceptURL := fmt.Sprintf("%s/invitations/%s", baseURL, token) + if err := services.SendInvitationEmail(req.Email, workspaceName, inviterEmail, acceptURL); err != nil { + log.Printf("invitation email: failed to send to %s: %v", req.Email, err) + } else { + log.Printf("invitation email: sent to %s for workspace %d", req.Email, workspaceID) + } + }() + response := models.InvitationResponse{ ID: int(invitationID), WorkspaceID: workspaceID, diff --git a/services/mailer.go b/services/mailer.go index c89ce6d..3dc5926 100644 --- a/services/mailer.go +++ b/services/mailer.go @@ -49,6 +49,47 @@ func PasswordResetEmailDeliveryConfigured() bool { return err == nil } +func SendInvitationEmail(toEmail, workspaceName, invitedByEmail, acceptURL string) error { + config, err := LoadSMTPConfigFromEnv() + if err != nil { + return err + } + + subject := fmt.Sprintf("You've been invited to join %s on Sentinent", workspaceName) + body := fmt.Sprintf( + "Hello,\r\n\r\n%s has invited you to join the workspace \"%s\" on Sentinent.\r\n\r\nAccept your invitation here:\r\n%s\r\n\r\nThis invitation expires in 7 days. If you weren't expecting this, you can safely ignore this email.\r\n", + invitedByEmail, workspaceName, acceptURL, + ) + + fromHeader := config.FromEmail + if config.FromName != "" { + fromHeader = (&mail.Address{Name: config.FromName, Address: config.FromEmail}).String() + } + + message := strings.Join([]string{ + "From: " + fromHeader, + "To: " + toEmail, + "Subject: " + subject, + "MIME-Version: 1.0", + "Content-Type: text/plain; charset=UTF-8", + "", + body, + }, "\r\n") + + var auth smtp.Auth + if config.Username != "" || config.Password != "" { + auth = smtp.PlainAuth("", config.Username, config.Password, config.Host) + } + + return smtp.SendMail( + fmt.Sprintf("%s:%d", config.Host, config.Port), + auth, + config.FromEmail, + []string{toEmail}, + []byte(message), + ) +} + func SendPasswordResetEmail(toEmail, resetURL string) error { config, err := LoadSMTPConfigFromEnv() if err != nil { From 825a2437e022a7b94c4c913a37689c9c6f3669a4 Mon Sep 17 00:00:00 2001 From: Sentinent Agent Date: Wed, 29 Apr 2026 04:35:10 +0000 Subject: [PATCH 2/4] fix(mailer): SMTP timeout + goroutine panic recovery - Replace bare smtp.SendMail (no timeout) with a custom TCP dialer that enforces a 15-second deadline on both the connection and the entire SMTP conversation; prevents the goroutine from blocking indefinitely when a firewall silently drops port 587 - Refactor shared message-building into buildMessage() helper; both SendInvitationEmail and SendPasswordResetEmail use sendSMTP() - Add defer recover() to the invitation email goroutine so any unexpected panic cannot crash the HTTP server before the 201 response is flushed to the client --- handlers/invitations.go | 6 ++ services/mailer.go | 121 +++++++++++++++++++++++++--------------- 2 files changed, 81 insertions(+), 46 deletions(-) diff --git a/handlers/invitations.go b/handlers/invitations.go index 7573c5a..415b80d 100644 --- a/handlers/invitations.go +++ b/handlers/invitations.go @@ -120,6 +120,12 @@ func CreateInvitation(w http.ResponseWriter, r *http.Request) { // Send invitation email — look up workspace name and inviter email for the message body. go func() { + defer func() { + if r := recover(); r != nil { + log.Printf("invitation email: goroutine panic (recovered): %v", r) + } + }() + var workspaceName string if err := database.DB.QueryRow("SELECT name FROM workspaces WHERE id = ?", workspaceID).Scan(&workspaceName); err != nil { log.Printf("invitation email: could not fetch workspace name for workspace %d: %v", workspaceID, err) diff --git a/services/mailer.go b/services/mailer.go index 3dc5926..441d422 100644 --- a/services/mailer.go +++ b/services/mailer.go @@ -1,17 +1,22 @@ package services import ( + "crypto/tls" "errors" "fmt" + "net" "net/mail" "net/smtp" "os" "strconv" "strings" + "time" ) var ErrSMTPNotConfigured = errors.New("smtp configuration is incomplete") +const smtpTimeout = 15 * time.Second + type SMTPConfig struct { Host string Port int @@ -49,24 +54,72 @@ func PasswordResetEmailDeliveryConfigured() bool { return err == nil } -func SendInvitationEmail(toEmail, workspaceName, invitedByEmail, acceptURL string) error { - config, err := LoadSMTPConfigFromEnv() +// sendSMTP dials the SMTP server with a hard 15-second timeout for both the +// TCP connection and the entire SMTP conversation. This prevents the caller +// from blocking indefinitely when a firewall silently drops the connection. +func sendSMTP(config SMTPConfig, message string, recipients []string) error { + addr := fmt.Sprintf("%s:%d", config.Host, config.Port) + + // Dial with explicit timeout so we fail fast instead of waiting minutes. + conn, err := net.DialTimeout("tcp", addr, smtpTimeout) if err != nil { - return err + return fmt.Errorf("smtp dial %s: %w", addr, err) } + // Apply a deadline to every subsequent read/write on this connection. + conn.SetDeadline(time.Now().Add(smtpTimeout)) //nolint:errcheck + defer conn.Close() - subject := fmt.Sprintf("You've been invited to join %s on Sentinent", workspaceName) - body := fmt.Sprintf( - "Hello,\r\n\r\n%s has invited you to join the workspace \"%s\" on Sentinent.\r\n\r\nAccept your invitation here:\r\n%s\r\n\r\nThis invitation expires in 7 days. If you weren't expecting this, you can safely ignore this email.\r\n", - invitedByEmail, workspaceName, acceptURL, - ) + c, err := smtp.NewClient(conn, config.Host) + if err != nil { + return fmt.Errorf("smtp client: %w", err) + } + defer c.Close() + + // Upgrade to TLS via STARTTLS if the server advertises it (port 587). + if ok, _ := c.Extension("STARTTLS"); ok { + if err = c.StartTLS(&tls.Config{ServerName: config.Host}); err != nil { + return fmt.Errorf("smtp starttls: %w", err) + } + } + // Authenticate when credentials are provided. + if config.Username != "" || config.Password != "" { + auth := smtp.PlainAuth("", config.Username, config.Password, config.Host) + if err = c.Auth(auth); err != nil { + return fmt.Errorf("smtp auth: %w", err) + } + } + + if err = c.Mail(config.FromEmail); err != nil { + return fmt.Errorf("smtp MAIL FROM: %w", err) + } + for _, to := range recipients { + if err = c.Rcpt(to); err != nil { + return fmt.Errorf("smtp RCPT TO <%s>: %w", to, err) + } + } + + wc, err := c.Data() + if err != nil { + return fmt.Errorf("smtp DATA: %w", err) + } + if _, err = wc.Write([]byte(message)); err != nil { + wc.Close() + return fmt.Errorf("smtp write body: %w", err) + } + if err = wc.Close(); err != nil { + return fmt.Errorf("smtp close data writer: %w", err) + } + return c.Quit() +} + +// buildMessage creates the raw SMTP message bytes from config and body. +func buildMessage(config SMTPConfig, toEmail, subject, body string) string { fromHeader := config.FromEmail if config.FromName != "" { fromHeader = (&mail.Address{Name: config.FromName, Address: config.FromEmail}).String() } - - message := strings.Join([]string{ + return strings.Join([]string{ "From: " + fromHeader, "To: " + toEmail, "Subject: " + subject, @@ -75,19 +128,21 @@ func SendInvitationEmail(toEmail, workspaceName, invitedByEmail, acceptURL strin "", body, }, "\r\n") +} - var auth smtp.Auth - if config.Username != "" || config.Password != "" { - auth = smtp.PlainAuth("", config.Username, config.Password, config.Host) +func SendInvitationEmail(toEmail, workspaceName, invitedByEmail, acceptURL string) error { + config, err := LoadSMTPConfigFromEnv() + if err != nil { + return err } - return smtp.SendMail( - fmt.Sprintf("%s:%d", config.Host, config.Port), - auth, - config.FromEmail, - []string{toEmail}, - []byte(message), + subject := fmt.Sprintf("You've been invited to join %s on Sentinent", workspaceName) + body := fmt.Sprintf( + "Hello,\r\n\r\n%s has invited you to join the workspace \"%s\" on Sentinent.\r\n\r\nAccept your invitation here:\r\n%s\r\n\r\nThis invitation expires in 7 days. If you weren't expecting this, you can safely ignore this email.\r\n", + invitedByEmail, workspaceName, acceptURL, ) + + return sendSMTP(config, buildMessage(config, toEmail, subject, body), []string{toEmail}) } func SendPasswordResetEmail(toEmail, resetURL string) error { @@ -102,31 +157,5 @@ func SendPasswordResetEmail(toEmail, resetURL string) error { resetURL, ) - fromHeader := config.FromEmail - if config.FromName != "" { - fromHeader = (&mail.Address{Name: config.FromName, Address: config.FromEmail}).String() - } - - message := strings.Join([]string{ - "From: " + fromHeader, - "To: " + toEmail, - "Subject: " + subject, - "MIME-Version: 1.0", - "Content-Type: text/plain; charset=UTF-8", - "", - body, - }, "\r\n") - - var auth smtp.Auth - if config.Username != "" || config.Password != "" { - auth = smtp.PlainAuth("", config.Username, config.Password, config.Host) - } - - return smtp.SendMail( - fmt.Sprintf("%s:%d", config.Host, config.Port), - auth, - config.FromEmail, - []string{toEmail}, - []byte(message), - ) + return sendSMTP(config, buildMessage(config, toEmail, subject, body), []string{toEmail}) } From 08c71ca0439d2d0a76ff2a4ee486f49c3bfdd68b Mon Sep 17 00:00:00 2001 From: Sentinent Agent Date: Wed, 29 Apr 2026 15:11:45 +0000 Subject: [PATCH 3/4] feat(invitations): expose invited email in ValidateInvitation response Add the invited email to the GET /api/invitations/:token response so the frontend can compare it against the currently logged-in user's email and show an explicit wrong-account error instead of a generic 403. --- handlers/invitations.go | 1 + 1 file changed, 1 insertion(+) diff --git a/handlers/invitations.go b/handlers/invitations.go index 415b80d..d8007f6 100644 --- a/handlers/invitations.go +++ b/handlers/invitations.go @@ -280,6 +280,7 @@ func ValidateInvitation(w http.ResponseWriter, r *http.Request) { response := map[string]interface{}{ "valid": true, + "email": invitation.Email, "workspace": map[string]interface{}{ "id": invitation.WorkspaceID, "name": workspaceName, From 5ab514c16077b64c52d0e426ed2bd357f3c6ef23 Mon Sep 17 00:00:00 2001 From: Sentinent Agent Date: Wed, 29 Apr 2026 15:24:09 +0000 Subject: [PATCH 4/4] feat(invitations): resend email endpoint + return accepted invitations in list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add POST /api/invitations/:token/resend — re-sends the invitation email for an existing unaccepted token; only the workspace owner can call it - Update ListInvitations to return all invitations (pending and accepted) so the frontend can show an 'Accepted' section alongside 'Pending' - Scan accepted_at in the list query so the status is visible --- handlers/invitations.go | 83 +++++++++++++++++++++++++++++++++++++++-- main.go | 2 + 2 files changed, 82 insertions(+), 3 deletions(-) diff --git a/handlers/invitations.go b/handlers/invitations.go index d8007f6..236fffa 100644 --- a/handlers/invitations.go +++ b/handlers/invitations.go @@ -192,11 +192,11 @@ func ListInvitations(w http.ResponseWriter, r *http.Request) { } rows, err := database.DB.Query( - `SELECT id, workspace_id, email, token, role, expires_at, created_by, created_at, updated_at + `SELECT id, workspace_id, email, token, role, expires_at, created_by, created_at, updated_at, accepted_at FROM invitations - WHERE workspace_id = ? AND accepted_at IS NULL AND expires_at > ? + WHERE workspace_id = ? ORDER BY created_at DESC`, - workspaceID, time.Now(), + workspaceID, ) if err != nil { http.Error(w, "Failed to fetch invitations", http.StatusInternalServerError) @@ -217,6 +217,7 @@ func ListInvitations(w http.ResponseWriter, r *http.Request) { &invitation.CreatedBy, &invitation.CreatedAt, &invitation.UpdatedAt, + &invitation.AcceptedAt, ); err != nil { http.Error(w, "Failed to scan invitation", http.StatusInternalServerError) return @@ -436,6 +437,82 @@ func CancelInvitation(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } + +func ResendInvitation(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + userID, ok := middleware.GetUserID(r.Context()) + if !ok { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Path: /api/invitations/:token/resend -> parts[2] = token + parts := splitPath(r.URL.Path) + if len(parts) < 3 { + http.Error(w, "Invalid path", http.StatusBadRequest) + return + } + token := parts[2] + + var invitation models.Invitation + var createdBy int + err := database.DB.QueryRow( + `SELECT id, workspace_id, email, role, expires_at, created_by + FROM invitations + WHERE token = ? AND accepted_at IS NULL`, + token, + ).Scan(&invitation.ID, &invitation.WorkspaceID, &invitation.Email, &invitation.Role, &invitation.ExpiresAt, &createdBy) + if err != nil { + http.Error(w, "Invalid or already accepted invitation", http.StatusNotFound) + return + } + if time.Now().After(invitation.ExpiresAt) { + http.Error(w, "Invitation has expired", http.StatusGone) + return + } + + isOwner, err := middleware.IsWorkspaceOwner(userID, invitation.WorkspaceID) + if err != nil || !isOwner { + http.Error(w, "Forbidden: Only owners can resend invitations", http.StatusForbidden) + return + } + + go func() { + defer func() { + if r := recover(); r != nil { + log.Printf("resend invitation email: goroutine panic (recovered): %v", r) + } + }() + var workspaceName string + if err := database.DB.QueryRow("SELECT name FROM workspaces WHERE id = ?", invitation.WorkspaceID).Scan(&workspaceName); err != nil { + log.Printf("resend invitation email: could not fetch workspace name: %v", err) + return + } + var inviterEmail string + if err := database.DB.QueryRow("SELECT email FROM users WHERE id = ?", userID).Scan(&inviterEmail); err != nil { + log.Printf("resend invitation email: could not fetch inviter email: %v", err) + return + } + baseURL := strings.TrimRight(strings.TrimSpace(os.Getenv("FRONTEND_BASE_URL")), "/") + if baseURL == "" { + baseURL = "http://localhost:4200" + } + acceptURL := fmt.Sprintf("%s/invitations/%s", baseURL, token) + if err := services.SendInvitationEmail(invitation.Email, workspaceName, inviterEmail, acceptURL); err != nil { + log.Printf("resend invitation email: failed to send to %s: %v", invitation.Email, err) + } else { + log.Printf("resend invitation email: sent to %s for workspace %d", invitation.Email, invitation.WorkspaceID) + } + }() + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{"status": "sent"}) +} + func generateSecureToken() (string, error) { bytes := make([]byte, 32) if _, err := rand.Read(bytes); err != nil { diff --git a/main.go b/main.go index 08871a3..b8c1877 100644 --- a/main.go +++ b/main.go @@ -139,6 +139,8 @@ func main() { middleware.AuthMiddleware(http.HandlerFunc(handlers.CancelInvitation)).ServeHTTP(w, r) case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/accept"): middleware.AuthMiddleware(http.HandlerFunc(handlers.AcceptInvitation)).ServeHTTP(w, r) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/resend"): + middleware.AuthMiddleware(http.HandlerFunc(handlers.ResendInvitation)).ServeHTTP(w, r) default: http.Error(w, "Not found", http.StatusNotFound) }