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:
@@ -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]
|
||||
}
|
||||
Reference in New Issue
Block a user