This commit is contained in:
2026-02-11 17:46:22 +03:00
commit eacfacb13b
266 changed files with 51337 additions and 0 deletions

View File

@@ -0,0 +1,50 @@
package mailer
import (
"os"
"strconv"
"strings"
)
type Config struct {
Host string
Port int
Username string
Password string
From string
StartTLS bool
}
func ConfigFromEnv() (Config, error) {
var cfg Config
cfg.Host = strings.TrimSpace(os.Getenv("SMTP_HOST"))
cfg.Username = strings.TrimSpace(os.Getenv("SMTP_USERNAME"))
cfg.Password = os.Getenv("SMTP_PASSWORD")
cfg.From = strings.TrimSpace(os.Getenv("SMTP_FROM"))
portStr := strings.TrimSpace(os.Getenv("SMTP_PORT"))
if portStr == "" {
cfg.Port = 587
} else {
p, err := strconv.Atoi(portStr)
if err != nil {
return Config{}, err
}
cfg.Port = p
}
startTLS := strings.TrimSpace(os.Getenv("SMTP_STARTTLS"))
if startTLS == "" {
cfg.StartTLS = true
} else {
cfg.StartTLS = strings.EqualFold(startTLS, "true") || startTLS == "1"
}
// minimal validation
if cfg.Host == "" || cfg.Username == "" || cfg.Password == "" || cfg.From == "" || cfg.Port <= 0 {
return Config{}, ErrInvalidConfig
}
return cfg, nil
}

View File

@@ -0,0 +1,270 @@
package mailer
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
"golang.org/x/oauth2/clientcredentials"
)
type GraphMailer struct {
httpClient *http.Client
from string // noreply@baggi.com.tr
replyTo string // opsiyonel
}
func NewGraphMailer() (*GraphMailer, error) {
tenantID := strings.TrimSpace(os.Getenv("AZURE_TENANT_ID"))
clientID := strings.TrimSpace(os.Getenv("AZURE_CLIENT_ID"))
clientSecret := strings.TrimSpace(os.Getenv("AZURE_CLIENT_SECRET"))
from := strings.TrimSpace(os.Getenv("MAIL_FROM"))
replyTo := strings.TrimSpace(os.Getenv("MAIL_REPLY_TO")) // opsiyonel
if tenantID == "" || clientID == "" || clientSecret == "" || from == "" {
return nil, fmt.Errorf("azure graph mailer env missing (AZURE_TENANT_ID/AZURE_CLIENT_ID/AZURE_CLIENT_SECRET/MAIL_FROM)")
}
conf := clientcredentials.Config{
ClientID: clientID,
ClientSecret: clientSecret,
TokenURL: fmt.Sprintf("https://login.microsoftonline.com/%s/oauth2/v2.0/token", tenantID),
Scopes: []string{"https://graph.microsoft.com/.default"},
}
httpClient := conf.Client(context.Background())
httpClient.Timeout = 25 * time.Second
log.Printf("✉️ Graph Mailer hazır (App-only token) | from=%s", from)
return &GraphMailer{
httpClient: httpClient,
from: from,
replyTo: replyTo,
}, nil
}
func (g *GraphMailer) Send(ctx context.Context, msg Message) error {
start := time.Now()
// ---------- validate ----------
cleanTo := make([]string, 0, len(msg.To))
for _, t := range msg.To {
t = strings.TrimSpace(t)
if t != "" {
cleanTo = append(cleanTo, t)
}
}
if len(cleanTo) == 0 {
return fmt.Errorf("recipient missing")
}
subject := strings.TrimSpace(msg.Subject)
if subject == "" {
return fmt.Errorf("subject missing")
}
// internal-safe adjustments
isInternal := allRecipientsInDomain(cleanTo, "baggi.com.tr")
if isInternal && !strings.HasPrefix(subject, "[BSSApp]") {
subject = "[BSSApp] " + subject
}
html := strings.TrimSpace(msg.BodyHTML)
text := strings.TrimSpace(msg.Body)
// Eğer sadece HTML geldiyse text üret (spam skorunu düşürür)
if text == "" && html != "" {
text = stripHTMLVerySimple(html)
}
// Eğer sadece text geldiyse basit HTML üret
if html == "" && text != "" {
html = "<pre style=\"font-family:Segoe UI, Arial, sans-serif; white-space:pre-wrap;\">" +
htmlEscape(text) + "</pre>"
}
if html == "" {
return fmt.Errorf("body missing (Body or BodyHTML)")
}
log.Printf("✉️ [MAIL] SEND START | from=%s | to=%v | internal=%v | subject=%s", g.from, cleanTo, isInternal, subject)
// ---------- build recipients ----------
type recipient struct {
EmailAddress struct {
Address string `json:"address"`
} `json:"emailAddress"`
}
toRecipients := make([]recipient, 0, len(cleanTo))
for _, m := range cleanTo {
r := recipient{}
r.EmailAddress.Address = m
toRecipients = append(toRecipients, r)
}
// ---------- headers to reduce auto-phish/spam signals ----------
headers := []map[string]string{
{
"name": "X-Mailer",
"value": "BSSApp Graph Mailer",
},
{
"name": "X-BSSApp-Internal",
"value": strconv.FormatBool(isInternal),
},
}
// replyTo (opsiyonel)
var replyToRecipients []recipient
if strings.TrimSpace(g.replyTo) != "" {
rt := recipient{}
rt.EmailAddress.Address = strings.TrimSpace(g.replyTo)
replyToRecipients = []recipient{rt}
}
// ---------- Graph payload ----------
message := map[string]any{
"subject": subject,
"body": map[string]string{
"contentType": "HTML",
"content": buildHTML(html, text, isInternal),
},
"toRecipients": toRecipients,
"internetMessageHeaders": headers,
"importance": "normal",
}
// replyTo SADECE doluysa ekle
if len(replyToRecipients) > 0 {
message["replyTo"] = replyToRecipients
}
payload := map[string]any{
"message": message,
"saveToSentItems": true,
}
b, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("json marshal: %w", err)
}
url := fmt.Sprintf(
"https://graph.microsoft.com/v1.0/users/%s/sendMail",
g.from,
)
req, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
url,
bytes.NewBuffer(b),
)
if err != nil {
return fmt.Errorf("new request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
res, err := g.httpClient.Do(req)
if err != nil {
return fmt.Errorf("graph request: %w", err)
}
defer res.Body.Close()
if res.StatusCode >= 300 {
bodyBytes, _ := io.ReadAll(res.Body)
log.Printf(
"❌ [MAIL] SEND FAILED | status=%s | body=%s",
res.Status,
string(bodyBytes),
)
return fmt.Errorf("graph send mail failed: %s", res.Status)
}
log.Printf(
"✅ [MAIL] SEND OK | to=%v | duration=%s",
cleanTo,
time.Since(start),
)
return nil
}
// ---------- helpers ----------
func allRecipientsInDomain(to []string, domain string) bool {
domain = strings.ToLower(strings.TrimSpace(domain))
for _, addr := range to {
addr = strings.ToLower(strings.TrimSpace(addr))
if !strings.HasSuffix(addr, "@"+domain) {
return false
}
}
return true
}
func buildHTML(htmlBody, textBody string, internal bool) string {
// Internal ise daha sade, daha az “marketing-like”
if internal {
return `
<div style="font-family:Segoe UI, Arial, sans-serif; font-size:14px; color:#1f1f1f;">
` + htmlBody + `
<hr style="border:none;border-top:1px solid #e5e5e5;margin:16px 0;" />
<div style="font-size:12px;color:#666;">
Bu e-posta BSSApp sistemi tarafından otomatik oluşturulmuştur.
</div>
</div>`
}
// External
return `
<div style="font-family:Segoe UI, Arial, sans-serif; font-size:14px; color:#1f1f1f;">
` + htmlBody + `
</div>`
}
// Çok basit “html -> text” (tam değil ama yeterli)
func stripHTMLVerySimple(s string) string {
s = strings.ReplaceAll(s, "<br>", "\n")
s = strings.ReplaceAll(s, "<br/>", "\n")
s = strings.ReplaceAll(s, "<br />", "\n")
s = strings.ReplaceAll(s, "</p>", "\n\n")
// kaba temizlik
for {
i := strings.Index(s, "<")
j := strings.Index(s, ">")
if i >= 0 && j > i {
s = s[:i] + s[j+1:]
continue
}
break
}
return strings.TrimSpace(s)
}
func htmlEscape(s string) string {
replacer := strings.NewReplacer(
"&", "&amp;",
"<", "&lt;",
">", "&gt;",
"\"", "&quot;",
"'", "&#39;",
)
return replacer.Replace(s)
}
func (g *GraphMailer) SendMail(to string, subject string, html string) error {
msg := Message{
To: []string{to},
Subject: subject,
BodyHTML: html,
}
// context burada internal olarak veriliyor
return g.Send(context.Background(), msg)
}

View File

@@ -0,0 +1,148 @@
package mailer
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"net/smtp"
"strings"
"time"
)
var ErrInvalidConfig = errors.New("invalid smtp config")
type Mailer struct {
cfg Config
}
type Message struct {
To []string
Subject string
Body string
BodyHTML string
}
func New(cfg Config) *Mailer {
return &Mailer{cfg: cfg}
}
func (m *Mailer) Send(ctx context.Context, msg Message) error {
if len(msg.To) == 0 {
return errors.New("recipient missing")
}
if strings.TrimSpace(msg.Subject) == "" {
return errors.New("subject missing")
}
// timeout kontrolü (ctx)
deadline, ok := ctx.Deadline()
if !ok {
// default timeout
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, 20*time.Second)
defer cancel()
deadline, _ = ctx.Deadline()
}
timeout := time.Until(deadline)
if timeout <= 0 {
return context.DeadlineExceeded
}
addr := fmt.Sprintf("%s:%d", m.cfg.Host, m.cfg.Port)
dialer := net.Dialer{Timeout: timeout}
conn, err := dialer.DialContext(ctx, "tcp", addr)
if err != nil {
return fmt.Errorf("smtp dial: %w", err)
}
defer conn.Close()
c, err := smtp.NewClient(conn, m.cfg.Host)
if err != nil {
return fmt.Errorf("smtp client: %w", err)
}
defer c.Close()
// STARTTLS
if m.cfg.StartTLS {
tlsCfg := &tls.Config{
ServerName: m.cfg.Host,
MinVersion: tls.VersionTLS12,
}
if err := c.StartTLS(tlsCfg); err != nil {
return fmt.Errorf("starttls: %w", err)
}
}
// AUTH
auth := smtp.PlainAuth("", m.cfg.Username, m.cfg.Password, m.cfg.Host)
if err := c.Auth(auth); err != nil {
return fmt.Errorf("smtp auth: %w", err)
}
// MAIL FROM
if err := c.Mail(m.cfg.From); err != nil {
return fmt.Errorf("mail from: %w", err)
}
// RCPT TO
for _, rcpt := range msg.To {
rcpt = strings.TrimSpace(rcpt)
if rcpt == "" {
continue
}
if err := c.Rcpt(rcpt); err != nil {
return fmt.Errorf("rcpt %s: %w", rcpt, err)
}
}
w, err := c.Data()
if err != nil {
return fmt.Errorf("data: %w", err)
}
// basit mime
contentType := "text/plain; charset=UTF-8"
body := msg.Body
if strings.TrimSpace(msg.BodyHTML) != "" {
contentType = "text/html; charset=UTF-8"
body = msg.BodyHTML
}
raw := buildMIME(m.cfg.From, msg.To, msg.Subject, contentType, body)
_, writeErr := w.Write([]byte(raw))
closeErr := w.Close()
if writeErr != nil {
return fmt.Errorf("write body: %w", writeErr)
}
if closeErr != nil {
return fmt.Errorf("close body: %w", closeErr)
}
if err := c.Quit(); err != nil {
return fmt.Errorf("quit: %w", err)
}
return nil
}
func buildMIME(from string, to []string, subject, contentType, body string) string {
// Subject UTF-8 basit hali (gerekirse sonra MIME encoded-word ekleriz)
headers := []string{
"From: " + from,
"To: " + strings.Join(to, ", "),
"Subject: " + subject,
"MIME-Version: 1.0",
"Content-Type: " + contentType,
"",
}
return strings.Join(headers, "\r\n") + "\r\n" + body + "\r\n"
}
type MailerInterface interface {
Send(ctx context.Context, msg Message) error
}

View File

@@ -0,0 +1,18 @@
package mailer
import "fmt"
func (m *GraphMailer) SendPasswordResetMail(toEmail string, resetURL string) error {
subject := "Parola Sıfırlama"
html := fmt.Sprintf(`
<p>Merhaba,</p>
<p>Parolanızı sıfırlamak için aşağıdaki bağlantıya tıklayın:</p>
<p>
<a href="%s">%s</a>
</p>
<p>Bu bağlantı <strong>30 dakika</strong> geçerlidir ve tek kullanımlıktır.</p>
`, resetURL, resetURL)
return m.SendMail(toEmail, subject, html)
}