ilk
This commit is contained in:
50
svc/internal/mailer/config.go
Normal file
50
svc/internal/mailer/config.go
Normal 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
|
||||
}
|
||||
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)
|
||||
}
|
||||
148
svc/internal/mailer/mailer.go
Normal file
148
svc/internal/mailer/mailer.go
Normal 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
|
||||
}
|
||||
18
svc/internal/mailer/password_reset.go
Normal file
18
svc/internal/mailer/password_reset.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user