Files
OpsLog/internal/email/email.go
T
rouggy 2695747db6 chore(email): name the server, encryption and attachment size on a send failure
"An existing connection was forcibly closed by the remote host" during DATA
reads the same whether the server refused the SIZE, hit an hourly quota, or
simply dropped the socket — and only the first is something an operator can act
on. The message now carries host:port, the encryption in use and the total
attachment size, so the size can be ruled in or out without a second attempt.

A path that does not exist is not counted: the mail library skips it, so
reporting it would describe a message that was never sent.
2026-08-13 12:09:57 +02:00

118 lines
3.3 KiB
Go

// Package email sends QSO recordings to correspondents via SMTP. Pure Go (no
// CGO) using go-mail; supports implicit SSL (465), STARTTLS (587) or none.
package email
import (
"fmt"
"os"
"time"
"github.com/wneessen/go-mail"
)
// Config is the user's SMTP configuration.
type Config struct {
Host string
Port int
User string
Password string
From string
ReplyTo string // optional Reply-To: where replies should go (e.g. a personal inbox)
Encryption string // "ssl" | "starttls" | "none"
Auth bool // SMTP requires authorization (send username/password)
}
func (c Config) opts() []mail.Option {
o := []mail.Option{mail.WithPort(c.Port), mail.WithTimeout(30 * time.Second)}
if c.Auth && c.User != "" {
// AutoDiscover negotiates whatever mechanism the server advertises
// (LOGIN, PLAIN, CRAM-MD5, …). OVH, for instance, rejects forced PLAIN.
o = append(o, mail.WithSMTPAuth(mail.SMTPAuthAutoDiscover), mail.WithUsername(c.User), mail.WithPassword(c.Password))
}
switch c.Encryption {
case "ssl":
o = append(o, mail.WithSSL())
case "none":
o = append(o, mail.WithTLSPolicy(mail.NoTLS))
default: // starttls
o = append(o, mail.WithTLSPolicy(mail.TLSMandatory))
}
return o
}
// Send delivers a plain-text email to `to`, optionally attaching a file.
func Send(cfg Config, to, subject, body, attachPath string) error {
var attach []string
if attachPath != "" {
attach = []string{attachPath}
}
return SendFiles(cfg, to, subject, body, attach)
}
// SendFiles is like Send but attaches any number of files (missing paths are
// skipped by the mail library at send time).
func SendFiles(cfg Config, to, subject, body string, attachPaths []string) error {
if cfg.Host == "" {
return fmt.Errorf("SMTP server not configured")
}
if to == "" {
return fmt.Errorf("no recipient e-mail")
}
from := cfg.From
if from == "" {
from = cfg.User
}
m := mail.NewMsg()
if err := m.From(from); err != nil {
return fmt.Errorf("bad sender %q: %w", from, err)
}
if err := m.To(to); err != nil {
return fmt.Errorf("bad recipient %q: %w", to, err)
}
if cfg.ReplyTo != "" {
if err := m.ReplyTo(cfg.ReplyTo); err != nil {
return fmt.Errorf("bad reply-to %q: %w", cfg.ReplyTo, err)
}
}
m.Subject(subject)
m.SetBodyString(mail.TypeTextPlain, body)
for _, p := range attachPaths {
if p != "" {
m.AttachFile(p)
}
}
client, err := mail.NewClient(cfg.Host, cfg.opts()...)
if err != nil {
return fmt.Errorf("smtp client: %w", err)
}
if err := client.DialAndSend(m); err != nil {
return fmt.Errorf("send via %s:%d (%s, %s): %w",
cfg.Host, cfg.Port, cfg.Encryption, describeSize(attachPaths), err)
}
return nil
}
// describeSize reports what was attached, in bytes.
//
// "An existing connection was forcibly closed" during DATA is the same message
// whether the server refused the size, hit a quota, or simply dropped the
// socket — and the first of those is the only one the operator can act on
// directly. Naming the size rules it in or out without a second attempt.
func describeSize(paths []string) string {
var total int64
n := 0
for _, p := range paths {
if p == "" {
continue
}
if fi, err := os.Stat(p); err == nil {
total += fi.Size()
n++
}
}
if n == 0 {
return "no attachment"
}
return fmt.Sprintf("%d attachment(s), %d KB", n, (total+1023)/1024)
}