Files
flatrender/services/notification/internal/sender/sender.go
T
soroush.asadi 507ac7e6a4
Build backend images / build content-svc (push) Failing after 56s
Build backend images / build file-svc (push) Failing after 47s
Build backend images / build gateway (push) Failing after 1m0s
Build backend images / build identity-svc (push) Failing after 56s
Build backend images / build notification-svc (push) Failing after 11s
Build backend images / build render-svc (push) Failing after 4m5s
Build backend images / build studio-svc (push) Failing after 56s
feat(notifications+admin): SMS (Kavenegar) + Email (SMTP) channels & templates
Backend (notification-svc):
- channel_config table (per-tenant Kavenegar + SMTP settings) + migration 18
- sender pkg: Kavenegar SMS client + SMTP mailer (STARTTLS / implicit TLS), stdlib only
- endpoints: GET/PUT /v1/channels[/:channel], POST /v1/sms/send, POST /v1/email/send
  (template + {{var}} rendering); deliveries logged
- seeded 3 Persian email templates: welcome / account_verification / promotion
- gateway routes /v1/{channels,sms,email}/* → notification

Admin UI:
- /admin/messaging: SMS + Email provider config cards, test-send, email template editor
- nav link + fa/en labels

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 17:32:54 +03:30

171 lines
4.4 KiB
Go

// 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]
}