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 = "
" +
			htmlEscape(text) + "
" } 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 `
` + htmlBody + `
Bu e-posta BSSApp sistemi tarafından otomatik oluşturulmuştur.
` } // External return `
` + htmlBody + `
` } // Çok basit “html -> text” (tam değil ama yeterli) func stripHTMLVerySimple(s string) string { s = strings.ReplaceAll(s, "
", "\n") s = strings.ReplaceAll(s, "
", "\n") s = strings.ReplaceAll(s, "
", "\n") s = strings.ReplaceAll(s, "

", "\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) }