mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-10-28 15:51:23 +00:00
HA scheduler: the job scheduler and alert evaluator hold session-scoped PostgreSQL advisory locks on pinned pool connections — replicas scan for due work exactly once, failover verified live with two replicas. Manual and hook-triggered runs stay available on any replica. Email surface: mailer renders registered templates (invitation); every send is logged to core.email_log with status and error text. New GET /projects/:id/emails endpoint; Members page shows an Email log card. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
88 lines
2.4 KiB
Go
88 lines
2.4 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/smtp"
|
|
"strings"
|
|
|
|
"github.com/resend/resend-go/v2"
|
|
|
|
"github.com/tdvorak/primora/apps/backend/internal/config"
|
|
)
|
|
|
|
// Mailer is transport-only: Resend when configured, plain SMTP otherwise.
|
|
// Templates render (subject, body) from data; sends are logged to
|
|
// core.email_log by the service layer so operators can see what went out.
|
|
type Mailer struct {
|
|
cfg config.Config
|
|
resend *resend.Client
|
|
}
|
|
|
|
func NewMailer(cfg config.Config) *Mailer {
|
|
var resendClient *resend.Client
|
|
if cfg.ResendAPIKey != "" {
|
|
resendClient = resend.NewClient(cfg.ResendAPIKey)
|
|
}
|
|
return &Mailer{cfg: cfg, resend: resendClient}
|
|
}
|
|
|
|
// mailTemplate renders a subject + plaintext body from template data.
|
|
type mailTemplate func(data map[string]string) (subject string, body string)
|
|
|
|
var mailTemplates = map[string]mailTemplate{
|
|
"invitation": func(d map[string]string) (string, string) {
|
|
subject := fmt.Sprintf("You were invited to %s on Primora", d["organization"])
|
|
body := fmt.Sprintf(
|
|
"You have been invited to Primora.\n\nOpen this link to accept the invitation:\n%s\n\nThis link expires in 72 hours.\n",
|
|
d["invite_url"],
|
|
)
|
|
return subject, body
|
|
},
|
|
}
|
|
|
|
func renderMailTemplate(template string, data map[string]string) (string, string, error) {
|
|
tpl, ok := mailTemplates[template]
|
|
if !ok {
|
|
return "", "", fmt.Errorf("unknown email template %q", template)
|
|
}
|
|
subject, body := tpl(data)
|
|
return subject, body, nil
|
|
}
|
|
|
|
func (m *Mailer) Send(ctx context.Context, toEmail, subject, text string) error {
|
|
if m.resend != nil {
|
|
_, err := m.resend.Emails.SendWithContext(ctx, &resend.SendEmailRequest{
|
|
From: m.cfg.MailFrom,
|
|
To: []string{toEmail},
|
|
Subject: subject,
|
|
Text: text,
|
|
})
|
|
return err
|
|
}
|
|
address := fmt.Sprintf("%s:%d", m.cfg.SMTPHost, m.cfg.SMTPPort)
|
|
message := strings.Join([]string{
|
|
"From: " + m.cfg.MailFrom,
|
|
"To: " + toEmail,
|
|
"Subject: " + subject,
|
|
"MIME-Version: 1.0",
|
|
"Content-Type: text/plain; charset=utf-8",
|
|
"",
|
|
text,
|
|
}, "\r\n")
|
|
var auth smtp.Auth
|
|
if m.cfg.SMTPUser != "" {
|
|
auth = smtp.PlainAuth("", m.cfg.SMTPUser, m.cfg.SMTPPassword, m.cfg.SMTPHost)
|
|
}
|
|
return smtp.SendMail(address, auth, extractEmail(m.cfg.MailFrom), []string{toEmail}, []byte(message))
|
|
}
|
|
|
|
func extractEmail(input string) string {
|
|
if start := strings.Index(input, "<"); start >= 0 {
|
|
if end := strings.Index(input, ">"); end > start {
|
|
return input[start+1 : end]
|
|
}
|
|
}
|
|
return input
|
|
}
|