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
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>
171 lines
4.4 KiB
Go
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]
|
|
}
|