package services import ( "bytes" "crypto/tls" "encoding/base64" "fmt" htmltemplate "html/template" "log" "net/smtp" "strings" texttemplate "text/template" "time" ) // EmailConfig holds SMTP configuration type EmailConfig struct { SMTPHost string SMTPPort string SMTPUser string SMTPPassword string FromEmail string ToEmail string } // EmailService handles email sending operations type EmailService struct { config *EmailConfig } // NewEmailService creates a new email service func NewEmailService(config *EmailConfig) *EmailService { return &EmailService{ config: config, } } // ContactFormData represents contact form submission data type ContactFormData struct { Email string Name string Company string Subject string Message string IP string Time time.Time } // Validate performs validation on contact form data func (c *ContactFormData) Validate() error { // Sanitize inputs c.Email = strings.TrimSpace(c.Email) c.Name = strings.TrimSpace(c.Name) c.Company = strings.TrimSpace(c.Company) c.Subject = strings.TrimSpace(c.Subject) c.Message = strings.TrimSpace(c.Message) // Required fields if c.Email == "" { return fmt.Errorf("email is required") } if c.Message == "" { return fmt.Errorf("message is required") } // Email format validation (basic) if !strings.Contains(c.Email, "@") || !strings.Contains(c.Email, ".") { return fmt.Errorf("invalid email format") } // Prevent email header injection if containsNewlines(c.Email) { return fmt.Errorf("invalid email: contains prohibited characters") } if containsNewlines(c.Subject) { return fmt.Errorf("invalid subject: contains prohibited characters") } // Length validation if len(c.Email) > 254 { return fmt.Errorf("email too long (max 254 characters)") } if len(c.Name) > 100 { return fmt.Errorf("name too long (max 100 characters)") } if len(c.Company) > 100 { return fmt.Errorf("company too long (max 100 characters)") } if len(c.Subject) > 200 { return fmt.Errorf("subject too long (max 200 characters)") } if len(c.Message) > 5000 { return fmt.Errorf("message too long (max 5000 characters)") } if len(c.Message) < 10 { return fmt.Errorf("message too short (min 10 characters)") } return nil } // containsNewlines checks for newline characters that could enable header injection func containsNewlines(s string) bool { return strings.ContainsAny(s, "\r\n") } // SendContactForm sends a contact form email with HTML and plain text versions func (e *EmailService) SendContactForm(data *ContactFormData) error { // Validate data if err := data.Validate(); err != nil { return fmt.Errorf("validation failed: %w", err) } // Prepare email content subject := "[CV Contact] " if data.Subject != "" { subject += data.Subject } else { subject += "New Message from " + data.Name } // Build email bodies (HTML and plain text) htmlBody, textBody, err := e.buildEmailBody(data) if err != nil { return fmt.Errorf("failed to build email body: %w", err) } // Send multipart email if err := e.sendMultipartEmail(subject, htmlBody, textBody, data.Email); err != nil { return fmt.Errorf("failed to send email: %w", err) } // Log successful send (without sensitive data) log.Printf("Contact form email sent successfully to %s from %s", e.config.ToEmail, data.Email) return nil } // emailTemplateData wraps ContactFormData with display-safe fields type emailTemplateData struct { Name string Email string Company string Subject string Message string IP string Time time.Time } // buildEmailBody creates both HTML and plain text email bodies func (e *EmailService) buildEmailBody(data *ContactFormData) (htmlBody, textBody string, err error) { // Prepare template data with safe defaults tmplData := emailTemplateData{ Name: data.Name, Email: data.Email, Company: data.Company, Subject: data.Subject, Message: data.Message, IP: data.IP, Time: data.Time, } // Set defaults for empty fields if tmplData.Name == "" { tmplData.Name = "Not provided" } // Build HTML body htmlTmpl, err := htmltemplate.New("contact-html").Parse(ContactEmailHTMLTemplate()) if err != nil { return "", "", fmt.Errorf("failed to parse HTML template: %w", err) } var htmlBuf bytes.Buffer if err := htmlTmpl.Execute(&htmlBuf, tmplData); err != nil { return "", "", fmt.Errorf("failed to execute HTML template: %w", err) } // Build plain text body textTmpl, err := texttemplate.New("contact-text").Parse(ContactEmailPlainTemplate()) if err != nil { return "", "", fmt.Errorf("failed to parse text template: %w", err) } var textBuf bytes.Buffer if err := textTmpl.Execute(&textBuf, tmplData); err != nil { return "", "", fmt.Errorf("failed to execute text template: %w", err) } return htmlBuf.String(), textBuf.String(), nil } // sendMultipartEmail sends an email with both HTML and plain text parts func (e *EmailService) sendMultipartEmail(subject, htmlBody, textBody, replyTo string) error { // Validate config if e.config.SMTPHost == "" || e.config.SMTPPort == "" { return fmt.Errorf("SMTP configuration incomplete") } if e.config.SMTPUser == "" || e.config.SMTPPassword == "" { return fmt.Errorf("SMTP credentials missing") } if e.config.ToEmail == "" { return fmt.Errorf("recipient email not configured") } from := e.config.FromEmail if from == "" { from = e.config.SMTPUser } to := e.config.ToEmail // Build multipart message message := e.formatMultipartMessage(from, to, replyTo, subject, htmlBody, textBody) // SMTP server address addr := fmt.Sprintf("%s:%s", e.config.SMTPHost, e.config.SMTPPort) // Setup authentication auth := smtp.PlainAuth("", e.config.SMTPUser, e.config.SMTPPassword, e.config.SMTPHost) // Connect to SMTP server with TLS client, err := e.connectSMTP(addr) if err != nil { return fmt.Errorf("failed to connect to SMTP server: %w", err) } defer func() { _ = client.Close() }() // Authenticate if err = client.Auth(auth); err != nil { return fmt.Errorf("SMTP authentication failed: %w", err) } // Set sender and recipient if err = client.Mail(from); err != nil { return fmt.Errorf("failed to set sender: %w", err) } if err = client.Rcpt(to); err != nil { return fmt.Errorf("failed to set recipient: %w", err) } // Send message w, err := client.Data() if err != nil { return fmt.Errorf("failed to get data writer: %w", err) } _, err = w.Write([]byte(message)) if err != nil { return fmt.Errorf("failed to write message: %w", err) } err = w.Close() if err != nil { return fmt.Errorf("failed to close writer: %w", err) } return client.Quit() } // connectSMTP establishes an SMTP connection with TLS func (e *EmailService) connectSMTP(addr string) (*smtp.Client, error) { tlsConfig := &tls.Config{ ServerName: e.config.SMTPHost, MinVersion: tls.VersionTLS12, } // Port 465 uses implicit SSL (direct TLS connection) // Port 587 uses STARTTLS (plain connection upgraded to TLS) if e.config.SMTPPort == "465" { // Implicit SSL: Connect with TLS from the start conn, err := tls.Dial("tcp", addr, tlsConfig) if err != nil { return nil, fmt.Errorf("TLS dial failed: %w", err) } client, err := smtp.NewClient(conn, e.config.SMTPHost) if err != nil { _ = conn.Close() return nil, fmt.Errorf("SMTP client creation failed: %w", err) } return client, nil } // STARTTLS: Connect plain, then upgrade to TLS client, err := smtp.Dial(addr) if err != nil { return nil, err } if err = client.StartTLS(tlsConfig); err != nil { _ = client.Close() return nil, err } return client, nil } // formatMultipartMessage formats a multipart email with HTML and plain text func (e *EmailService) formatMultipartMessage(from, to, replyTo, subject, htmlBody, textBody string) string { // Generate boundary for multipart boundary := fmt.Sprintf("----=_Part_%d", time.Now().UnixNano()) var message strings.Builder // Headers message.WriteString(fmt.Sprintf("From: %s\r\n", from)) message.WriteString(fmt.Sprintf("To: %s\r\n", to)) if replyTo != "" { message.WriteString(fmt.Sprintf("Reply-To: %s\r\n", replyTo)) } message.WriteString(fmt.Sprintf("Subject: %s\r\n", subject)) message.WriteString("MIME-Version: 1.0\r\n") message.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=\"%s\"\r\n", boundary)) message.WriteString(fmt.Sprintf("Date: %s\r\n", time.Now().Format(time.RFC1123Z))) message.WriteString("\r\n") // Plain text part message.WriteString(fmt.Sprintf("--%s\r\n", boundary)) message.WriteString("Content-Type: text/plain; charset=\"utf-8\"\r\n") message.WriteString("Content-Transfer-Encoding: quoted-printable\r\n") message.WriteString("\r\n") message.WriteString(textBody) message.WriteString("\r\n") // HTML part message.WriteString(fmt.Sprintf("--%s\r\n", boundary)) message.WriteString("Content-Type: text/html; charset=\"utf-8\"\r\n") message.WriteString("Content-Transfer-Encoding: base64\r\n") message.WriteString("\r\n") // Encode HTML as base64 for safe transmission encoded := base64.StdEncoding.EncodeToString([]byte(htmlBody)) // Split into 76-character lines per RFC 2045 for i := 0; i < len(encoded); i += 76 { end := i + 76 if end > len(encoded) { end = len(encoded) } message.WriteString(encoded[i:end]) message.WriteString("\r\n") } // End boundary message.WriteString(fmt.Sprintf("--%s--\r\n", boundary)) return message.String() }