114 lines
3.6 KiB
Go
114 lines
3.6 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"net"
|
|
"net/mail"
|
|
"net/smtp"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type EmailConfig struct {
|
|
Host, Port, Username, Password, From string
|
|
}
|
|
|
|
func EmailConfigFromEnv() (*EmailConfig, error) {
|
|
c := &EmailConfig{Host: os.Getenv("SMTP_HOST"), Port: os.Getenv("SMTP_PORT"), Username: os.Getenv("SMTP_USERNAME"), Password: os.Getenv("SMTP_PASSWORD"), From: os.Getenv("SMTP_FROM")}
|
|
if c.Host == "" && c.Port == "" && c.Username == "" && c.Password == "" && c.From == "" {
|
|
return nil, nil
|
|
}
|
|
address, err := mail.ParseAddress(c.From)
|
|
if c.Host == "" || c.Port != "465" || c.Username == "" || c.Password == "" || err != nil || address.Address != c.From || strings.ContainsAny(c.Host+c.Username+c.From, "\r\n") {
|
|
return nil, errors.New("invalid SMTP configuration; TLS port 465 and a plain sender address are required")
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
func (c *EmailConfig) send(recipient, subject, body string) error {
|
|
if strings.ContainsAny(recipient+subject, "\r\n") {
|
|
return errors.New("invalid mail headers")
|
|
}
|
|
connection, err := tls.DialWithDialer(&net.Dialer{Timeout: 10 * time.Second}, "tcp", net.JoinHostPort(c.Host, c.Port), &tls.Config{ServerName: c.Host, MinVersion: tls.VersionTLS12})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer connection.Close()
|
|
_ = connection.SetDeadline(time.Now().Add(30 * time.Second))
|
|
client, err := smtp.NewClient(connection, c.Host)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer client.Close()
|
|
if err = client.Auth(smtp.PlainAuth("", c.Username, c.Password, c.Host)); err != nil {
|
|
return err
|
|
}
|
|
if err = client.Mail(c.From); err != nil {
|
|
return err
|
|
}
|
|
if err = client.Rcpt(recipient); err != nil {
|
|
return err
|
|
}
|
|
writer, err := client.Data()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
message := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s\r\n", c.From, recipient, subject, body)
|
|
if _, err = writer.Write([]byte(message)); err != nil {
|
|
return err
|
|
}
|
|
if err = writer.Close(); err != nil {
|
|
return err
|
|
}
|
|
return client.Quit()
|
|
}
|
|
|
|
func (c *EmailConfig) Run(ctx context.Context, db *sql.DB) {
|
|
if c == nil {
|
|
return
|
|
}
|
|
ticker := time.NewTicker(3 * time.Second)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
if err := c.deliver(ctx, db); err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
log.Printf("email delivery failed (%T)", err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
func (c *EmailConfig) deliver(ctx context.Context, db *sql.DB) error {
|
|
var id, lease, recipient, subject, body string
|
|
var attempts int
|
|
err := db.QueryRowContext(ctx, `
|
|
UPDATE email_outbox SET lease_id=gen_random_uuid(),lease_until=now()+interval '2 minutes',attempts=attempts+1
|
|
WHERE id=(SELECT id FROM email_outbox WHERE sent_at IS NULL AND expires_at>now() AND attempts<10
|
|
AND available_at<=now() AND (lease_until IS NULL OR lease_until<=now())
|
|
ORDER BY available_at,id FOR UPDATE SKIP LOCKED LIMIT 1)
|
|
RETURNING id::text,lease_id::text,recipient,subject,body,attempts
|
|
`).Scan(&id, &lease, &recipient, &subject, &body, &attempts)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
sendErr := c.send(recipient, subject, body)
|
|
if sendErr == nil {
|
|
_, err = db.ExecContext(ctx, "UPDATE email_outbox SET sent_at=now(),body='',lease_until=NULL,lease_id=NULL WHERE id=$1 AND lease_id=$2", id, lease)
|
|
return err
|
|
}
|
|
delay := 30 * (1 << min(attempts, 7))
|
|
_, err = db.ExecContext(ctx, "UPDATE email_outbox SET available_at=now()+$3*interval '1 second',lease_until=NULL,lease_id=NULL WHERE id=$1 AND lease_id=$2", id, lease, delay)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return sendErr
|
|
}
|