// Package sender implements the actual SMS (Kavenegar) and Email (SMTP) delivery. // Stdlib only — keeps the Go build hermetic/vendored. package sender import ( "crypto/tls" "encoding/base64" "encoding/json" "fmt" "io" "net/http" "net/smtp" "net/url" "strings" "time" ) // ── SMS via Kavenegar ──────────────────────────────────────────────────────── type kavenegarResp struct { Return struct { Status int `json:"status"` Message string `json:"message"` } `json:"return"` Entries []struct { MessageID int64 `json:"messageid"` Status int `json:"status"` } `json:"entries"` } // SendSMS sends a message via the Kavenegar REST API. Returns the provider message id. func SendSMS(apiKey, line, receptor, message string) (string, error) { if apiKey == "" { return "", fmt.Errorf("kavenegar api key not configured") } endpoint := fmt.Sprintf("https://api.kavenegar.com/v1/%s/sms/send.json", url.PathEscape(apiKey)) form := url.Values{} form.Set("receptor", receptor) form.Set("message", message) if line != "" { form.Set("sender", line) } client := &http.Client{Timeout: 15 * time.Second} resp, err := client.PostForm(endpoint, form) if err != nil { return "", fmt.Errorf("kavenegar request failed: %w", err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) var kr kavenegarResp if err := json.Unmarshal(body, &kr); err != nil { return "", fmt.Errorf("kavenegar unexpected response (%d): %s", resp.StatusCode, truncate(string(body), 200)) } if kr.Return.Status != 200 { return "", fmt.Errorf("kavenegar error %d: %s", kr.Return.Status, kr.Return.Message) } if len(kr.Entries) > 0 { return fmt.Sprintf("%d", kr.Entries[0].MessageID), nil } return "", nil } // ── Email via SMTP ─────────────────────────────────────────────────────────── type SMTPConfig struct { Host string Port int Username string Password string FromEmail string FromName string UseTLS bool // implicit TLS (e.g. port 465); otherwise STARTTLS is attempted } // SendEmail sends an HTML email. Supports implicit TLS (465) and STARTTLS (587/25). func SendEmail(cfg SMTPConfig, to, subject, htmlBody string) error { if cfg.Host == "" { return fmt.Errorf("smtp host not configured") } port := cfg.Port if port == 0 { port = 587 } from := cfg.FromEmail if from == "" { from = cfg.Username } fromHeader := from if cfg.FromName != "" { fromHeader = fmt.Sprintf("%s <%s>", cfg.FromName, from) } msg := strings.Join([]string{ "From: " + fromHeader, "To: " + to, "Subject: " + mimeEncode(subject), "MIME-Version: 1.0", "Content-Type: text/html; charset=UTF-8", "", htmlBody, }, "\r\n") addr := fmt.Sprintf("%s:%d", cfg.Host, port) auth := smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host) // Implicit TLS (port 465 style) if cfg.UseTLS || port == 465 { conn, err := tls.Dial("tcp", addr, &tls.Config{ServerName: cfg.Host}) if err != nil { return fmt.Errorf("smtp tls dial: %w", err) } client, err := smtp.NewClient(conn, cfg.Host) if err != nil { return fmt.Errorf("smtp client: %w", err) } defer client.Close() return sendVia(client, auth, from, to, msg) } // STARTTLS (587/25) — smtp.SendMail upgrades to TLS when the server offers it. return smtp.SendMail(addr, auth, from, []string{to}, []byte(msg)) } func sendVia(client *smtp.Client, auth smtp.Auth, from, to, msg string) error { if ok, _ := client.Extension("AUTH"); ok { if err := client.Auth(auth); err != nil { return fmt.Errorf("smtp auth: %w", err) } } if err := client.Mail(from); err != nil { return err } if err := client.Rcpt(to); err != nil { return err } w, err := client.Data() if err != nil { return err } if _, err := w.Write([]byte(msg)); err != nil { return err } if err := w.Close(); err != nil { return err } return client.Quit() } func mimeEncode(s string) string { // RFC 2047 encoded-word for non-ASCII subjects (Persian). for _, r := range s { if r > 127 { return "=?UTF-8?B?" + base64Std(s) + "?=" } } return s } func base64Std(s string) string { return base64.StdEncoding.EncodeToString([]byte(s)) } func truncate(s string, n int) string { if len(s) <= n { return s } return s[:n] }