Files
ntfy/mail/mail.go
T
2026-03-29 22:47:38 -04:00

127 lines
3.2 KiB
Go

package mail
import (
"crypto/rand"
"fmt"
"math/big"
"mime"
"net"
"net/smtp"
"strings"
"sync"
"time"
"heckel.io/ntfy/v2/log"
)
const (
verifyCodeExpiry = 10 * time.Minute
verifyCodeLength = 6
verifyCodeSubject = "ntfy email verification"
)
// Config holds the SMTP configuration for the mail sender
type Config struct {
SMTPAddr string // SMTP server address (host:port)
SMTPUser string // SMTP auth username
SMTPPass string // SMTP auth password
From string // Sender email address
}
// Sender sends emails and manages email verification codes
type Sender struct {
config *Config
verifyCodes map[string]verifyCode // keyed by email
mu sync.Mutex
}
type verifyCode struct {
code string
expires time.Time
}
// NewSender creates a new mail Sender with the given SMTP config
func NewSender(config *Config) *Sender {
return &Sender{
config: config,
verifyCodes: make(map[string]verifyCode),
}
}
// Send sends a plain text email via SMTP
func (s *Sender) Send(to, subject, body string) error {
host, _, err := net.SplitHostPort(s.config.SMTPAddr)
if err != nil {
return err
}
var auth smtp.Auth
if s.config.SMTPUser != "" {
auth = smtp.PlainAuth("", s.config.SMTPUser, s.config.SMTPPass, host)
}
date := time.Now().UTC().Format(time.RFC1123Z)
encodedSubject := mime.BEncoding.Encode("utf-8", subject)
message := `From: ntfy <{from}>
To: {to}
Date: {date}
Subject: {subject}
Content-Type: text/plain; charset="utf-8"
{body}`
message = strings.ReplaceAll(message, "{from}", s.config.From)
message = strings.ReplaceAll(message, "{to}", to)
message = strings.ReplaceAll(message, "{date}", date)
message = strings.ReplaceAll(message, "{subject}", encodedSubject)
message = strings.ReplaceAll(message, "{body}", body)
log.Tag("mail").Field("email_to", to).Debug("Sending email")
return smtp.SendMail(s.config.SMTPAddr, auth, s.config.From, []string{to}, []byte(message))
}
// SendVerification generates a 6-digit code, stores it in-memory, and sends a verification email
func (s *Sender) SendVerification(to string) error {
code, err := generateCode()
if err != nil {
return err
}
s.mu.Lock()
s.verifyCodes[to] = verifyCode{
code: code,
expires: time.Now().Add(verifyCodeExpiry),
}
s.mu.Unlock()
body := fmt.Sprintf("Your ntfy email verification code is: %s\n\nThis code expires in 10 minutes.", code)
return s.Send(to, verifyCodeSubject, body)
}
// CheckVerification checks if the code matches and hasn't expired. Removes the entry on success.
func (s *Sender) CheckVerification(email, code string) bool {
s.mu.Lock()
defer s.mu.Unlock()
vc, ok := s.verifyCodes[email]
if !ok || time.Now().After(vc.expires) || vc.code != code {
return false
}
delete(s.verifyCodes, email)
return true
}
// ExpireVerificationCodes removes expired entries from the in-memory map
func (s *Sender) ExpireVerificationCodes() {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
for email, vc := range s.verifyCodes {
if now.After(vc.expires) {
delete(s.verifyCodes, email)
}
}
}
func generateCode() (string, error) {
max := big.NewInt(1000000) // 0-999999
n, err := rand.Int(rand.Reader, max)
if err != nil {
return "", err
}
return fmt.Sprintf("%06d", n.Int64()), nil
}