Files
bssapp/svc/internal/mailer/graph_mailer.go
2026-02-11 17:46:22 +03:00

271 lines
6.5 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}