feat(notifications+admin): SMS (Kavenegar) + Email (SMTP) channels & templates
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
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
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>
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
// 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]
|
||||
}
|
||||
Reference in New Issue
Block a user