271 lines
6.5 KiB
Go
271 lines
6.5 KiB
Go
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)
|
||
}
|