Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 116 additions & 3 deletions handlers/invitations.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
108 changes: 89 additions & 19 deletions services/mailer.go
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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})
}
Loading