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,32 @@
|
||||
-- =====================================================================
|
||||
-- NOTIFICATION SCHEMA — Part 18: channel provider config + seed email templates
|
||||
-- Stores per-tenant SMS (Kavenegar) and Email (SMTP) provider settings, and seeds
|
||||
-- the default Persian email templates (welcome / account verification / promotion).
|
||||
-- =====================================================================
|
||||
|
||||
SET search_path TO notification, public;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channel_config (
|
||||
tenant_id UUID NOT NULL,
|
||||
channel TEXT NOT NULL, -- 'sms' | 'email'
|
||||
settings JSONB NOT NULL DEFAULT '{}',-- kavenegar:{api_key,line_number} / smtp:{host,port,username,password,from_email,from_name,use_tls}
|
||||
enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (tenant_id, channel)
|
||||
);
|
||||
|
||||
-- Seed default Email templates (idempotent; no dependency on a unique constraint).
|
||||
INSERT INTO notification_templates (code, channel, locale, subject, body_html, is_active)
|
||||
SELECT v.code, v.channel, v.locale, v.subject, v.body_html, TRUE
|
||||
FROM (VALUES
|
||||
('welcome', 'Email', 'fa', 'به فلترندر خوش آمدید 🎉',
|
||||
'<div dir="rtl" style="font-family:Tahoma,sans-serif;max-width:560px;margin:auto;padding:32px;background:#fff;color:#171717"><h1 style="color:#2563eb">به فلترندر خوش آمدید!</h1><p>سلام {{name}}، خوشحالیم که به جمع سازندگان فلترندر پیوستی. حالا میتونی با هوش مصنوعی و بیش از ۱٬۲۰۰ قالب، ویدیو و تصویر حرفهای بسازی.</p><p style="text-align:center;margin:32px 0"><a href="{{app_url}}" style="background:linear-gradient(90deg,#7c3aed,#2563eb);color:#fff;padding:14px 28px;border-radius:12px;text-decoration:none;font-weight:bold">شروع ساخت</a></p><p style="color:#737373;font-size:13px">اگر این حساب را شما نساختهاید، این ایمیل را نادیده بگیرید.</p></div>'),
|
||||
('account_verification', 'Email', 'fa', 'تأیید حساب کاربری فلترندر',
|
||||
'<div dir="rtl" style="font-family:Tahoma,sans-serif;max-width:560px;margin:auto;padding:32px;background:#fff;color:#171717"><h1 style="color:#2563eb">تأیید ایمیل</h1><p>سلام {{name}}، برای فعالسازی حساب خود کد زیر را وارد کنید:</p><p style="text-align:center;font-size:32px;font-weight:bold;letter-spacing:8px;color:#2563eb;margin:28px 0">{{code}}</p><p style="color:#737373;font-size:13px">این کد تا ۱۵ دقیقه معتبر است. اگر شما درخواست ندادهاید، این پیام را نادیده بگیرید.</p></div>'),
|
||||
('promotion', 'Email', 'fa', 'پیشنهاد ویژهٔ فلترندر ✨',
|
||||
'<div dir="rtl" style="font-family:Tahoma,sans-serif;max-width:560px;margin:auto;padding:32px;background:#fff;color:#171717"><h1 style="color:#7c3aed">{{title}}</h1><p>{{body}}</p><p style="text-align:center;margin:32px 0"><a href="{{cta_url}}" style="background:linear-gradient(90deg,#7c3aed,#2563eb);color:#fff;padding:14px 28px;border-radius:12px;text-decoration:none;font-weight:bold">{{cta_text}}</a></p><p style="color:#737373;font-size:13px">برای لغو دریافت این پیامها از تنظیمات حساب خود اقدام کنید.</p></div>')
|
||||
) AS v(code, channel, locale, subject, body_html)
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM notification_templates t
|
||||
WHERE t.code = v.code AND t.channel = v.channel AND t.locale = v.locale
|
||||
);
|
||||
+2
-1
@@ -323,7 +323,8 @@
|
||||
"templates": "Templates",
|
||||
"media": "Media",
|
||||
"discounts": "Discounts",
|
||||
"siteSettings": "Settings"
|
||||
"siteSettings": "Settings",
|
||||
"messaging": "Messaging"
|
||||
},
|
||||
"appAdminNodesPage": {
|
||||
"title": "Render Nodes",
|
||||
|
||||
+2
-1
@@ -323,7 +323,8 @@
|
||||
"templates": "قالبها",
|
||||
"media": "رسانه",
|
||||
"discounts": "تخفیفها",
|
||||
"siteSettings": "تنظیمات سایت"
|
||||
"siteSettings": "تنظیمات سایت",
|
||||
"messaging": "پیامرسانی"
|
||||
},
|
||||
"appAdminNodesPage": {
|
||||
"title": "نودهای رندر",
|
||||
|
||||
@@ -139,6 +139,9 @@ func main() {
|
||||
// ── Notification Service ──────────────────────────────────────────────────
|
||||
v1.Any("/notifications/*path", apiRL, auth, notification.Handler())
|
||||
v1.Any("/notification-templates/*path", apiRL, auth, notification.Handler())
|
||||
v1.Any("/channels/*path", apiRL, auth, notification.Handler())
|
||||
v1.Any("/sms/*path", apiRL, auth, notification.Handler())
|
||||
v1.Any("/email/*path", apiRL, auth, notification.Handler())
|
||||
|
||||
log.Printf("gateway listening on :%s", port)
|
||||
if err := r.Run(":" + port); err != nil {
|
||||
|
||||
@@ -39,6 +39,7 @@ func main() {
|
||||
notifH := handlers.NewNotificationHandler(store)
|
||||
prefH := handlers.NewPreferenceHandler(store)
|
||||
tplH := handlers.NewTemplateHandler(store)
|
||||
chH := handlers.NewChannelHandler(store)
|
||||
|
||||
r := gin.Default()
|
||||
|
||||
@@ -86,6 +87,12 @@ func main() {
|
||||
v1.GET("/notification-templates", auth, admin, tplH.List)
|
||||
v1.PUT("/notification-templates", auth, admin, tplH.Upsert)
|
||||
|
||||
// ── Channels: SMS (Kavenegar) + Email (SMTP) config & send (admin) ────────
|
||||
v1.GET("/channels", auth, admin, chH.ListConfigs)
|
||||
v1.PUT("/channels/:channel", auth, admin, chH.UpsertConfig)
|
||||
v1.POST("/sms/send", auth, admin, chH.SendSMS)
|
||||
v1.POST("/email/send", auth, admin, chH.SendEmail)
|
||||
|
||||
// ── Internal: create notification (service-to-service) ───────────────────
|
||||
v1.POST("/internal/notifications", serviceAuth, notifH.CreateInternal)
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/flatrender/notification-svc/internal/models"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// ── Channel provider config ──────────────────────────────────────────────────
|
||||
|
||||
func (s *Store) GetChannelConfigs(ctx context.Context, tenantID uuid.UUID) ([]*models.ChannelConfig, error) {
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`SELECT tenant_id, channel, settings, enabled, updated_at
|
||||
FROM notification.channel_config WHERE tenant_id = $1 ORDER BY channel`, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []*models.ChannelConfig
|
||||
for rows.Next() {
|
||||
c := &models.ChannelConfig{}
|
||||
var raw []byte
|
||||
if err := rows.Scan(&c.TenantID, &c.Channel, &raw, &c.Enabled, &c.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = json.Unmarshal(raw, &c.Settings)
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) GetChannelConfig(ctx context.Context, tenantID uuid.UUID, channel string) (*models.ChannelConfig, error) {
|
||||
c := &models.ChannelConfig{}
|
||||
var raw []byte
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT tenant_id, channel, settings, enabled, updated_at
|
||||
FROM notification.channel_config WHERE tenant_id = $1 AND channel = $2`, tenantID, channel).
|
||||
Scan(&c.TenantID, &c.Channel, &raw, &c.Enabled, &c.UpdatedAt)
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = json.Unmarshal(raw, &c.Settings)
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpsertChannelConfig(ctx context.Context, tenantID uuid.UUID, channel string, settings map[string]any, enabled bool) error {
|
||||
raw, _ := json.Marshal(settings)
|
||||
_, err := s.pool.Exec(ctx,
|
||||
`INSERT INTO notification.channel_config (tenant_id, channel, settings, enabled, updated_at)
|
||||
VALUES ($1, $2, $3, $4, NOW())
|
||||
ON CONFLICT (tenant_id, channel)
|
||||
DO UPDATE SET settings = EXCLUDED.settings, enabled = EXCLUDED.enabled, updated_at = NOW()`,
|
||||
tenantID, channel, raw, enabled)
|
||||
return err
|
||||
}
|
||||
|
||||
// ── Email template lookup ────────────────────────────────────────────────────
|
||||
|
||||
func (s *Store) GetEmailTemplate(ctx context.Context, code, locale string) (*models.NotificationTemplate, error) {
|
||||
t := &models.NotificationTemplate{}
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT id, code, channel, locale, subject, body_text, body_html, is_active
|
||||
FROM notification.notification_templates
|
||||
WHERE code = $1 AND channel = 'Email' AND locale = $2 AND is_active = TRUE
|
||||
LIMIT 1`, code, locale).
|
||||
Scan(&t.ID, &t.Code, &t.Channel, &t.Locale, &t.Subject, &t.BodyText, &t.BodyHTML, &t.IsActive)
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return t, err
|
||||
}
|
||||
|
||||
// ── Delivery log ─────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Store) LogDelivery(ctx context.Context, tenantID, userID uuid.UUID, channel, recipient string,
|
||||
subject, provider, providerMsgID, status, errMsg *string) error {
|
||||
_, err := s.pool.Exec(ctx,
|
||||
`INSERT INTO notification.notification_deliveries
|
||||
(tenant_id, user_id, channel, recipient, subject, provider, provider_message_id, status, error_message, sent_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW())`,
|
||||
tenantID, userID, channel, recipient, subject, provider, providerMsgID, status, errMsg)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/flatrender/notification-svc/internal/db"
|
||||
"github.com/flatrender/notification-svc/internal/middleware"
|
||||
"github.com/flatrender/notification-svc/internal/models"
|
||||
"github.com/flatrender/notification-svc/internal/sender"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type ChannelHandler struct{ store *db.Store }
|
||||
|
||||
func NewChannelHandler(s *db.Store) *ChannelHandler { return &ChannelHandler{store: s} }
|
||||
|
||||
func (h *ChannelHandler) tenant(c *gin.Context) uuid.UUID {
|
||||
v, _ := c.Get(middleware.CtxTenantID)
|
||||
id, _ := v.(uuid.UUID)
|
||||
return id
|
||||
}
|
||||
func (h *ChannelHandler) user(c *gin.Context) uuid.UUID {
|
||||
v, _ := c.Get(middleware.CtxUserID)
|
||||
id, _ := v.(uuid.UUID)
|
||||
return id
|
||||
}
|
||||
|
||||
// GET /v1/channels — both provider configs, secrets masked.
|
||||
func (h *ChannelHandler) ListConfigs(c *gin.Context) {
|
||||
cfgs, err := h.store.GetChannelConfigs(c.Request.Context(), h.tenant(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.APIError{Code: "db_error", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
out := map[string]*models.ChannelConfig{
|
||||
"sms": {Channel: "sms", Settings: map[string]any{}},
|
||||
"email": {Channel: "email", Settings: map[string]any{}},
|
||||
}
|
||||
for _, cf := range cfgs {
|
||||
out[cf.Channel] = cf
|
||||
}
|
||||
mask(out["sms"], "api_key")
|
||||
mask(out["email"], "password")
|
||||
c.JSON(http.StatusOK, gin.H{"sms": out["sms"], "email": out["email"]})
|
||||
}
|
||||
|
||||
// PUT /v1/channels/:channel — upsert provider settings (blank secret keeps existing).
|
||||
func (h *ChannelHandler) UpsertConfig(c *gin.Context) {
|
||||
channel := c.Param("channel")
|
||||
if channel != "sms" && channel != "email" {
|
||||
c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: "channel must be sms or email"})
|
||||
return
|
||||
}
|
||||
var req models.UpsertChannelConfigRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
settings := req.Settings
|
||||
if settings == nil {
|
||||
settings = map[string]any{}
|
||||
}
|
||||
enabled := false
|
||||
if req.Enabled != nil {
|
||||
enabled = *req.Enabled
|
||||
}
|
||||
secretKey := "api_key"
|
||||
if channel == "email" {
|
||||
secretKey = "password"
|
||||
}
|
||||
// Preserve the secret if the client sent it blank/omitted (it's masked on read).
|
||||
existing, _ := h.store.GetChannelConfig(c.Request.Context(), h.tenant(c), channel)
|
||||
if existing != nil {
|
||||
if v, ok := settings[secretKey]; !ok || str(v) == "" || strings.Contains(str(v), "•") {
|
||||
if ev, ok := existing.Settings[secretKey]; ok {
|
||||
settings[secretKey] = ev
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := h.store.UpsertChannelConfig(c.Request.Context(), h.tenant(c), channel, settings, enabled); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.APIError{Code: "db_error", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// POST /v1/sms/send — send an SMS via Kavenegar.
|
||||
func (h *ChannelHandler) SendSMS(c *gin.Context) {
|
||||
var req models.SendSMSRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
cfg, _ := h.store.GetChannelConfig(c.Request.Context(), h.tenant(c), "sms")
|
||||
if cfg == nil || !cfg.Enabled {
|
||||
c.JSON(http.StatusBadRequest, models.APIError{Code: "not_configured", Message: "SMS channel is not configured/enabled"})
|
||||
return
|
||||
}
|
||||
msgID, err := sender.SendSMS(str(cfg.Settings["api_key"]), str(cfg.Settings["line_number"]), req.To, req.Message)
|
||||
status, errMsg := "Sent", (*string)(nil)
|
||||
if err != nil {
|
||||
status = "Failed"
|
||||
e := err.Error()
|
||||
errMsg = &e
|
||||
}
|
||||
provider := "kavenegar"
|
||||
_ = h.store.LogDelivery(c.Request.Context(), h.tenant(c), h.user(c), "SMS", req.To, nil, &provider, ptr(msgID), &status, errMsg)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, models.APIError{Code: "send_failed", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "sent", "message_id": msgID})
|
||||
}
|
||||
|
||||
// POST /v1/email/send — send an email via SMTP (optionally from a template + variables).
|
||||
func (h *ChannelHandler) SendEmail(c *gin.Context) {
|
||||
var req models.SendEmailRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
subject, html := req.Subject, req.BodyHTML
|
||||
if req.TemplateCode != "" {
|
||||
locale := req.Locale
|
||||
if locale == "" {
|
||||
locale = "fa"
|
||||
}
|
||||
tpl, _ := h.store.GetEmailTemplate(c.Request.Context(), req.TemplateCode, locale)
|
||||
if tpl == nil {
|
||||
c.JSON(http.StatusNotFound, models.APIError{Code: "not_found", Message: "email template not found"})
|
||||
return
|
||||
}
|
||||
if tpl.Subject != nil {
|
||||
subject = *tpl.Subject
|
||||
}
|
||||
if tpl.BodyHTML != nil {
|
||||
html = *tpl.BodyHTML
|
||||
}
|
||||
for k, v := range req.Variables {
|
||||
subject = strings.ReplaceAll(subject, "{{"+k+"}}", v)
|
||||
html = strings.ReplaceAll(html, "{{"+k+"}}", v)
|
||||
}
|
||||
}
|
||||
cfg, _ := h.store.GetChannelConfig(c.Request.Context(), h.tenant(c), "email")
|
||||
if cfg == nil || !cfg.Enabled {
|
||||
c.JSON(http.StatusBadRequest, models.APIError{Code: "not_configured", Message: "Email channel is not configured/enabled"})
|
||||
return
|
||||
}
|
||||
scfg := sender.SMTPConfig{
|
||||
Host: str(cfg.Settings["host"]),
|
||||
Port: intv(cfg.Settings["port"]),
|
||||
Username: str(cfg.Settings["username"]),
|
||||
Password: str(cfg.Settings["password"]),
|
||||
FromEmail: str(cfg.Settings["from_email"]),
|
||||
FromName: str(cfg.Settings["from_name"]),
|
||||
UseTLS: boolv(cfg.Settings["use_tls"]),
|
||||
}
|
||||
err := sender.SendEmail(scfg, req.To, subject, html)
|
||||
status, errMsg := "Sent", (*string)(nil)
|
||||
if err != nil {
|
||||
status = "Failed"
|
||||
e := err.Error()
|
||||
errMsg = &e
|
||||
}
|
||||
provider := "smtp"
|
||||
_ = h.store.LogDelivery(c.Request.Context(), h.tenant(c), h.user(c), "Email", req.To, &subject, &provider, nil, &status, errMsg)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, models.APIError{Code: "send_failed", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "sent"})
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func mask(cfg *models.ChannelConfig, key string) {
|
||||
if cfg == nil || cfg.Settings == nil {
|
||||
return
|
||||
}
|
||||
if v, ok := cfg.Settings[key]; ok && str(v) != "" {
|
||||
s := str(v)
|
||||
if len(s) > 4 {
|
||||
cfg.Settings[key] = "••••••••" + s[len(s)-4:]
|
||||
} else {
|
||||
cfg.Settings[key] = "••••"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func str(v any) string {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
func intv(v any) int {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int(n)
|
||||
case int:
|
||||
return n
|
||||
}
|
||||
return 0
|
||||
}
|
||||
func boolv(v any) bool {
|
||||
b, _ := v.(bool)
|
||||
return b
|
||||
}
|
||||
func ptr(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
@@ -193,3 +193,33 @@ type APIError struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// ── Channel provider config (SMS / Email) ────────────────────────────────────
|
||||
|
||||
type ChannelConfig struct {
|
||||
TenantID uuid.UUID `json:"tenant_id"`
|
||||
Channel string `json:"channel"` // 'sms' | 'email'
|
||||
Settings map[string]any `json:"settings"`
|
||||
Enabled bool `json:"enabled"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type UpsertChannelConfigRequest struct {
|
||||
Settings map[string]any `json:"settings"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type SendSMSRequest struct {
|
||||
To string `json:"to" binding:"required"`
|
||||
Message string `json:"message" binding:"required"`
|
||||
}
|
||||
|
||||
type SendEmailRequest struct {
|
||||
To string `json:"to" binding:"required"`
|
||||
Subject string `json:"subject"`
|
||||
BodyHTML string `json:"body_html"`
|
||||
BodyText string `json:"body_text"`
|
||||
TemplateCode string `json:"template_code"`
|
||||
Locale string `json:"locale"`
|
||||
Variables map[string]string `json:"variables"`
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
@@ -24,6 +24,7 @@ export default async function AdminLayout({
|
||||
{ href: "/admin/slides", label: t("slides") },
|
||||
{ href: "/admin/files", label: t("media") },
|
||||
{ href: "/admin/ai", label: t("aiContent") },
|
||||
{ href: "/admin/messaging", label: t("messaging") },
|
||||
{ href: "/admin/users", label: t("users") },
|
||||
{ href: "/admin/plans", label: t("plans") },
|
||||
{ href: "/admin/discounts", label: t("discounts") },
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { MessagingAdmin } from "@/components/admin/MessagingAdmin";
|
||||
|
||||
export default function Page() {
|
||||
return <MessagingAdmin />;
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
interface ChannelCfg { channel: string; enabled: boolean; settings: Record<string, unknown> }
|
||||
interface Tpl { id?: string; code: string; channel: string; locale: string; subject?: string | null; body_html?: string | null; is_active?: boolean }
|
||||
|
||||
const card = "rounded-xl border border-[#1e2235] bg-[#0f1120] p-5";
|
||||
const btn = "rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500 disabled:opacity-50";
|
||||
const ghost = "rounded-lg border border-[#262b40] px-3 py-1.5 text-xs text-gray-300 hover:bg-[#161a2e] disabled:opacity-50";
|
||||
const inp = "w-full rounded-lg border border-[#262b40] bg-[#0c0e1a] px-3 py-2 text-sm text-gray-100 outline-none focus:border-indigo-500";
|
||||
const lbl = "mb-1 block text-xs font-medium text-gray-400";
|
||||
const s = (v: unknown) => (v == null ? "" : String(v));
|
||||
|
||||
export function MessagingAdmin() {
|
||||
const [sms, setSms] = useState<ChannelCfg>({ channel: "sms", enabled: false, settings: {} });
|
||||
const [email, setEmail] = useState<ChannelCfg>({ channel: "email", enabled: false, settings: {} });
|
||||
const [tpls, setTpls] = useState<Tpl[]>([]);
|
||||
const [editTpl, setEditTpl] = useState<Tpl | null>(null);
|
||||
const [msg, setMsg] = useState<Record<string, string>>({});
|
||||
|
||||
const setS = (k: string, v: unknown) => setSms((c) => ({ ...c, settings: { ...c.settings, [k]: v } }));
|
||||
const setE = (k: string, v: unknown) => setEmail((c) => ({ ...c, settings: { ...c.settings, [k]: v } }));
|
||||
const flash = (k: string, t: string) => { setMsg((m) => ({ ...m, [k]: t })); setTimeout(() => setMsg((m) => ({ ...m, [k]: "" })), 2500); };
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
const ch = await fetch("/api/admin/resource/channels", { cache: "no-store" }).then((r) => r.json()).catch(() => null);
|
||||
if (ch?.sms) setSms({ channel: "sms", enabled: !!ch.sms.enabled, settings: ch.sms.settings ?? {} });
|
||||
if (ch?.email) setEmail({ channel: "email", enabled: !!ch.email.enabled, settings: ch.email.settings ?? {} });
|
||||
const t = await fetch("/api/admin/resource/notification-templates", { cache: "no-store" }).then((r) => r.json()).catch(() => null);
|
||||
const list: Tpl[] = Array.isArray(t) ? t : t?.data ?? t?.items ?? [];
|
||||
setTpls(list.filter((x) => x.channel === "Email"));
|
||||
}, []);
|
||||
useEffect(() => { reload(); }, [reload]);
|
||||
|
||||
const saveChannel = async (ch: "sms" | "email", cfg: ChannelCfg) => {
|
||||
const res = await fetch(`/api/admin/resource/channels/${ch}`, {
|
||||
method: "PUT", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ settings: cfg.settings, enabled: cfg.enabled }),
|
||||
});
|
||||
flash(ch, res.ok ? "ذخیره شد ✓" : "خطا در ذخیره");
|
||||
if (res.ok) reload();
|
||||
};
|
||||
|
||||
const sendTest = async (ch: "sms" | "email", body: object) => {
|
||||
const res = await fetch(`/api/admin/resource/${ch}/send`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
|
||||
});
|
||||
const d = await res.json().catch(() => null);
|
||||
flash(ch + "_send", res.ok ? "ارسال شد ✓" : (d?.error ?? "ارسال ناموفق"));
|
||||
};
|
||||
|
||||
const saveTpl = async () => {
|
||||
if (!editTpl) return;
|
||||
const res = await fetch("/api/admin/resource/notification-templates", {
|
||||
method: "PUT", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
code: editTpl.code, channel: "Email", locale: editTpl.locale || "fa",
|
||||
subject: editTpl.subject, body_html: editTpl.body_html, is_active: editTpl.is_active ?? true,
|
||||
}),
|
||||
});
|
||||
flash("tpl", res.ok ? "قالب ذخیره شد ✓" : "خطا");
|
||||
if (res.ok) { setEditTpl(null); reload(); }
|
||||
};
|
||||
|
||||
const [smsTo, setSmsTo] = useState(""); const [smsMsg, setSmsMsg] = useState("");
|
||||
const [emTo, setEmTo] = useState(""); const [emSub, setEmSub] = useState(""); const [emBody, setEmBody] = useState("");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-white">Messaging — SMS & Email</h1>
|
||||
<p className="mt-1 text-sm text-gray-400">Configure providers, send test messages, and edit email templates. Enter your real keys when ready.</p>
|
||||
</div>
|
||||
|
||||
{/* SMS / Kavenegar */}
|
||||
<section className={card}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-white">SMS · Kavenegar</h2>
|
||||
<label className="flex items-center gap-2 text-sm text-gray-300"><input type="checkbox" checked={sms.enabled} onChange={(e) => setSms({ ...sms, enabled: e.target.checked })} /> فعال</label>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
<div><label className={lbl}>API Key (توکن کاوهنگار)</label><input className={inp} type="password" placeholder={s(sms.settings.api_key) || "API key"} value={s(sms.settings.api_key).includes("•") ? "" : s(sms.settings.api_key)} onChange={(e) => setS("api_key", e.target.value)} /></div>
|
||||
<div><label className={lbl}>Line number (شماره خط)</label><input className={inp} value={s(sms.settings.line_number)} onChange={(e) => setS("line_number", e.target.value)} /></div>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
<button className={btn} onClick={() => saveChannel("sms", sms)}>ذخیره تنظیمات SMS</button>
|
||||
{msg.sms && <span className="text-xs text-gray-400">{msg.sms}</span>}
|
||||
</div>
|
||||
<div className="mt-4 border-t border-[#1e2235] pt-4">
|
||||
<label className={lbl}>ارسال آزمایشی</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<input className={`${inp} max-w-[180px]`} placeholder="شماره گیرنده" value={smsTo} onChange={(e) => setSmsTo(e.target.value)} />
|
||||
<input className={`${inp} flex-1`} placeholder="متن پیامک" value={smsMsg} onChange={(e) => setSmsMsg(e.target.value)} />
|
||||
<button className={ghost} disabled={!smsTo || !smsMsg} onClick={() => sendTest("sms", { to: smsTo, message: smsMsg })}>ارسال</button>
|
||||
</div>
|
||||
{msg.sms_send && <span className="mt-2 block text-xs text-gray-400">{msg.sms_send}</span>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Email / SMTP */}
|
||||
<section className={card}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-white">Email · SMTP</h2>
|
||||
<label className="flex items-center gap-2 text-sm text-gray-300"><input type="checkbox" checked={email.enabled} onChange={(e) => setEmail({ ...email, enabled: e.target.checked })} /> فعال</label>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
<div><label className={lbl}>SMTP Host</label><input className={inp} value={s(email.settings.host)} onChange={(e) => setE("host", e.target.value)} placeholder="smtp.example.com" /></div>
|
||||
<div><label className={lbl}>Port</label><input className={inp} type="number" value={s(email.settings.port)} onChange={(e) => setE("port", Number(e.target.value))} placeholder="587" /></div>
|
||||
<div><label className={lbl}>Username</label><input className={inp} value={s(email.settings.username)} onChange={(e) => setE("username", e.target.value)} /></div>
|
||||
<div><label className={lbl}>Password</label><input className={inp} type="password" placeholder={s(email.settings.password) || "password"} value={s(email.settings.password).includes("•") ? "" : s(email.settings.password)} onChange={(e) => setE("password", e.target.value)} /></div>
|
||||
<div><label className={lbl}>From email</label><input className={inp} value={s(email.settings.from_email)} onChange={(e) => setE("from_email", e.target.value)} placeholder="no-reply@flatrender.com" /></div>
|
||||
<div><label className={lbl}>From name</label><input className={inp} value={s(email.settings.from_name)} onChange={(e) => setE("from_name", e.target.value)} placeholder="FlatRender" /></div>
|
||||
<label className="flex items-center gap-2 text-sm text-gray-300"><input type="checkbox" checked={!!email.settings.use_tls} onChange={(e) => setE("use_tls", e.target.checked)} /> TLS مستقیم (پورت ۴۶۵)</label>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
<button className={btn} onClick={() => saveChannel("email", email)}>ذخیره تنظیمات Email</button>
|
||||
{msg.email && <span className="text-xs text-gray-400">{msg.email}</span>}
|
||||
</div>
|
||||
<div className="mt-4 border-t border-[#1e2235] pt-4">
|
||||
<label className={lbl}>ارسال آزمایشی</label>
|
||||
<div className="grid gap-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<input className={`${inp} max-w-[240px]`} placeholder="ایمیل گیرنده" value={emTo} onChange={(e) => setEmTo(e.target.value)} />
|
||||
<input className={`${inp} flex-1`} placeholder="موضوع" value={emSub} onChange={(e) => setEmSub(e.target.value)} />
|
||||
</div>
|
||||
<textarea className={`${inp} min-h-[70px]`} placeholder="<p>متن HTML ایمیل</p>" value={emBody} onChange={(e) => setEmBody(e.target.value)} />
|
||||
<div><button className={ghost} disabled={!emTo} onClick={() => sendTest("email", { to: emTo, subject: emSub, body_html: emBody })}>ارسال ایمیل آزمایشی</button>
|
||||
{msg.email_send && <span className="ms-2 text-xs text-gray-400">{msg.email_send}</span>}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Email templates */}
|
||||
<section className={card}>
|
||||
<h2 className="text-sm font-semibold text-white">قالبهای ایمیل</h2>
|
||||
<p className="mt-1 text-xs text-gray-500">قالبهای welcome / account_verification / promotion. متغیرها بهصورت {"{{name}}"} نوشته میشوند.</p>
|
||||
<div className="mt-4 overflow-hidden rounded-lg border border-[#262b40]">
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="border-b border-[#1e2235] text-left text-xs text-gray-500"><th className="px-3 py-2">Code</th><th className="px-3 py-2">Subject</th><th className="px-3 py-2">Locale</th><th className="px-3 py-2 text-right">—</th></tr></thead>
|
||||
<tbody>
|
||||
{tpls.length === 0 ? (
|
||||
<tr><td colSpan={4} className="px-3 py-6 text-center text-gray-500">قالبی یافت نشد.</td></tr>
|
||||
) : tpls.map((t) => (
|
||||
<tr key={t.code + t.locale} className="border-b border-[#161a2e]">
|
||||
<td className="px-3 py-2 font-mono text-xs text-gray-200">{t.code}</td>
|
||||
<td className="px-3 py-2 text-gray-300">{t.subject}</td>
|
||||
<td className="px-3 py-2 text-gray-500">{t.locale}</td>
|
||||
<td className="px-3 py-2 text-right"><button className={ghost} onClick={() => setEditTpl({ ...t })}>ویرایش</button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{msg.tpl && <span className="mt-2 block text-xs text-gray-400">{msg.tpl}</span>}
|
||||
</section>
|
||||
|
||||
{editTpl && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={() => setEditTpl(null)}>
|
||||
<div className={`${card} w-full max-w-2xl`} onClick={(e) => e.stopPropagation()}>
|
||||
<h2 className="text-sm font-semibold text-white">ویرایش قالب: {editTpl.code}</h2>
|
||||
<div className="mt-4 grid gap-3">
|
||||
<div><label className={lbl}>موضوع</label><input className={inp} value={editTpl.subject ?? ""} onChange={(e) => setEditTpl({ ...editTpl, subject: e.target.value })} /></div>
|
||||
<div><label className={lbl}>بدنه HTML</label><textarea className={`${inp} min-h-[220px] font-mono text-xs`} value={editTpl.body_html ?? ""} onChange={(e) => setEditTpl({ ...editTpl, body_html: e.target.value })} /></div>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button className={ghost} onClick={() => setEditTpl(null)}>انصراف</button>
|
||||
<button className={btn} onClick={saveTpl}>ذخیره قالب</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user