Files
gochat/backend/internal/service/profile_confirmation_mailer.go
T
rogee aeddedf2a3 Reorganize repo: backend/, deploy/, docs/ layout + AGENTS.md
Restructure the monorepo into clear top-level directories:
- backend/: Go module root (cmd, internal, pkg, configs, migrations,
  docs/swagger, scripts, tests, go.mod, Makefile, .air.toml)
- deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd
- docs/: project documentation + reports/ (moved from repo root)
- AGENTS.md: new AI coding-agent guide at repo root

Update all references to the new layout:
- Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root)
- docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile,
  env_file ../../.env, volume mounts ../../backend:/app
- deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile
- CI: working-directory: backend for go commands, file deploy/docker/Dockerfile,
  coverage path backend/coverage.out, health_check backend/scripts/
- backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../
- README: architecture tree, quickstart, config paths updated

Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh,
gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to
preserve history. Build, vet, SQLite tests, and docker compose config verified.
2026-07-07 14:44:12 +08:00

210 lines
7.0 KiB
Go

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 <accounts@chatwoot.com>"
}
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"), "/")
}