package service import ( "context" "fmt" "net/mail" "net/smtp" "os" "strings" "github.com/gochat/gochat/internal/model" ) const confirmationInstructionsSubject = "Confirmation Instructions" // ProfileConfirmationMailer delivers Devise-compatible confirmation instructions. // Reference: Chatwoot Devise::Mailer#confirmation_instructions. type ProfileConfirmationMailer interface { SendConfirmationInstructions(ctx context.Context, req ProfileConfirmationMailRequest) error } type ProfileConfirmationMailRequest struct { Kind string UserID uint AccountID uint InviterID uint ToEmail string RecipientName string BrandName string AccountName string InviterName string Subject string Eyebrow string Heading string IntroText string SupportingText string ActionText string ActionURL string ConfirmationToken string ResetPasswordToken string DetailRows [][2]string } type SMTPProfileConfirmationMailer struct { Address string Port int Username string Password string From string FrontendURL string } func NewEnvProfileConfirmationMailer() *SMTPProfileConfirmationMailer { return &SMTPProfileConfirmationMailer{ Address: strings.TrimSpace(os.Getenv("SMTP_ADDRESS")), Port: envInt("SMTP_PORT", 587), Username: firstEnv("SMTP_USERNAME", "SMTP_LOGIN"), Password: os.Getenv("SMTP_PASSWORD"), From: firstEnv("MAILER_SENDER_EMAIL", "SMTP_FROM"), FrontendURL: envConfirmationFrontendURL(), } } func (m *SMTPProfileConfirmationMailer) SendConfirmationInstructions(ctx context.Context, req ProfileConfirmationMailRequest) error { _ = ctx if m == nil || strings.TrimSpace(m.Address) == "" || strings.TrimSpace(req.ToEmail) == "" { return nil } fromHeader := strings.TrimSpace(m.From) if fromHeader == "" { fromHeader = "Chatwoot " } fromAddress := fromHeader if parsed, err := mail.ParseAddress(fromHeader); err == nil { fromAddress = parsed.Address } if strings.TrimSpace(req.Subject) == "" { req.Subject = confirmationInstructionsSubject } message := smtpMessage(fromHeader, req.ToEmail, req.Subject, profileConfirmationEmailBody(req)) addr := fmt.Sprintf("%s:%d", strings.TrimSpace(m.Address), m.Port) var auth smtp.Auth if strings.TrimSpace(m.Username) != "" { auth = smtp.PlainAuth("", strings.TrimSpace(m.Username), m.Password, strings.TrimSpace(m.Address)) } return smtp.SendMail(addr, auth, fromAddress, []string{req.ToEmail}, []byte(message)) } func buildProfileConfirmationMailRequest(user *model.User, account *model.Account, inviter *model.User, brandName, frontendURL, confirmationToken, resetPasswordToken string) ProfileConfirmationMailRequest { recipientName := strings.TrimSpace(user.Name) if recipientName == "" { recipientName = strings.TrimSpace(user.Email) } req := ProfileConfirmationMailRequest{ Kind: "confirmation", UserID: user.ID, ToEmail: strings.TrimSpace(user.Email), RecipientName: recipientName, BrandName: brandName, Subject: confirmationInstructionsSubject, Eyebrow: "Welcome", Heading: "Confirm your email to get started", IntroText: fmt.Sprintf("Welcome to %s. We just need to verify your email address before you can start using your account.", brandName), SupportingText: "This only takes a moment.", ActionText: "Confirm my account", ActionURL: frontendActionURL(frontendURL, "auth/confirmation", "confirmation_token", confirmationToken), ConfirmationToken: confirmationToken, } if strings.TrimSpace(user.UnconfirmedEmail) != "" { req.Kind = "email_update" req.Eyebrow = "Email update" req.Heading = "Confirm your new email address" req.IntroText = fmt.Sprintf("We received a request to update the email address on your %s account.", brandName) req.SupportingText = "Confirm the new address below to finish the change." req.ActionText = "Confirm email address" req.DetailRows = append(req.DetailRows, [2]string{"New email", user.UnconfirmedEmail}) return req } if user.ConfirmedAt != nil { req.Kind = "already_confirmed" req.Eyebrow = "Account ready" req.Heading = "Your account is ready" req.IntroText = fmt.Sprintf("Your %s account is already active.", brandName) req.SupportingText = "Use the button below to sign in and continue where you left off." req.ActionText = "Open my account" req.ActionURL = frontendActionURL(frontendURL, "auth/sign_in", "", "") req.ConfirmationToken = "" return req } if inviter != nil { req.Kind = "invitation" req.InviterID = inviter.ID req.InviterName = strings.TrimSpace(inviter.Name) if req.InviterName == "" { req.InviterName = strings.TrimSpace(inviter.Email) } req.Eyebrow = "Workspace invitation" req.ActionText = "Accept invitation" req.ActionURL = frontendActionURL(frontendURL, "auth/password/edit", "reset_password_token", resetPasswordToken) req.ConfirmationToken = "" req.ResetPasswordToken = resetPasswordToken req.SupportingText = "Create your account to start collaborating with your team." req.DetailRows = append(req.DetailRows, [2]string{"Invited by", req.InviterName}) if account != nil { req.AccountID = account.ID req.AccountName = strings.TrimSpace(account.Name) } if req.AccountName != "" { req.Heading = fmt.Sprintf("You're invited to join %s", req.AccountName) req.IntroText = fmt.Sprintf("%s invited you to join the %s workspace on %s.", req.InviterName, req.AccountName, brandName) req.DetailRows = append(req.DetailRows, [2]string{"Workspace", req.AccountName}) } else { req.Heading = fmt.Sprintf("You're invited to try %s", brandName) req.IntroText = fmt.Sprintf("%s invited you to try %s.", req.InviterName, brandName) } } return req } func profileConfirmationEmailBody(req ProfileConfirmationMailRequest) string { var b strings.Builder b.WriteString(fmt.Sprintf("Hi %s,\n\n", req.RecipientName)) if req.Eyebrow != "" { b.WriteString(req.Eyebrow + "\n") } if req.Heading != "" { b.WriteString(req.Heading + "\n\n") } if req.IntroText != "" { b.WriteString(req.IntroText + "\n") } if req.SupportingText != "" { b.WriteString(req.SupportingText + "\n") } for _, row := range req.DetailRows { if strings.TrimSpace(row[1]) != "" { b.WriteString(fmt.Sprintf("%s: %s\n", row[0], row[1])) } } if req.ActionText != "" && req.ActionURL != "" { b.WriteString(fmt.Sprintf("\n%s: %s\n", req.ActionText, req.ActionURL)) } return b.String() } func frontendActionURL(frontendURL, path, key, value string) string { base := strings.TrimRight(strings.TrimSpace(frontendURL), "/") if base == "" { base = "/app" } else { base += "/app" } url := base + "/" + strings.TrimLeft(path, "/") if key != "" && value != "" { url += "?" + key + "=" + value } return url } func envConfirmationFrontendURL() string { return strings.TrimRight(os.Getenv("FRONTEND_URL"), "/") }