diff --git a/handlers/invitations.go b/handlers/invitations.go index 8481e29..236fffa 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,37 @@ 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() { + 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) + 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, @@ -157,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) @@ -182,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 @@ -245,6 +281,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, @@ -400,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) } diff --git a/services/mailer.go b/services/mailer.go index c89ce6d..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 SendPasswordResetEmail(toEmail, resetURL 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 := "Reset your Sentinent password" - body := fmt.Sprintf( - "Hello,\r\n\r\nWe received a request to reset your Sentinent password.\r\n\r\nUse this link to choose a new password:\r\n%s\r\n\r\nThis link expires in 1 hour. If you didn't request this change, you can ignore this email.\r\n", - resetURL, - ) + 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,17 +128,34 @@ func SendPasswordResetEmail(toEmail, resetURL string) error { "", 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 + } + + 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 { + 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 := "Reset your Sentinent password" + body := fmt.Sprintf( + "Hello,\r\n\r\nWe received a request to reset your Sentinent password.\r\n\r\nUse this link to choose a new password:\r\n%s\r\n\r\nThis link expires in 1 hour. If you didn't request this change, you can ignore this email.\r\n", + resetURL, ) + + return sendSMTP(config, buildMessage(config, toEmail, subject, body), []string{toEmail}) }