ilk
This commit is contained in:
270
svc/internal/mailer/graph_mailer.go
Normal file
270
svc/internal/mailer/graph_mailer.go
Normal 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(
|
||||
"&", "&",
|
||||
"<", "<",
|
||||
">", ">",
|
||||
"\"", """,
|
||||
"'", "'",
|
||||
)
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user