// 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" "strings" "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%s", cfg.Host, cfg.Port, cfg.Encryption, describeSize(attachPaths), err, explainSMTP(err)) } return nil } // explainSMTP turns a server's refusal into the thing to go and do. // // A rejection is quoted verbatim above it — the server's own words are the // evidence — but several of them name a policy rather than a mistake, and no // amount of re-checking the password will fix those. Microsoft's is the one // operators keep hitting: basic authentication for SMTP is switched off across // Microsoft 365 and outlook.com, and an app password does not bring it back. func explainSMTP(err error) string { msg := strings.ToLower(err.Error()) switch { case strings.Contains(msg, "basic authentication is disabled"), strings.Contains(msg, "5.7.139"): return "\n\nMicrosoft has switched off password-based SMTP for this account. " + "An app password does not restore it — the server refuses the password itself, not the one you typed. " + "On a Microsoft 365 tenant an administrator can re-enable it for this mailbox " + "(Set-CASMailbox -SmtpClientAuthenticationDisabled $false, plus the tenant-wide setting); " + "otherwise use another provider for alerts (a Gmail account with an app password works, so does any ordinary IMAP/SMTP host)." case strings.Contains(msg, "application-specific password"), strings.Contains(msg, "5.7.9"): return "\n\nThis account needs an APP PASSWORD rather than the one you sign in with " + "(Google, Yahoo and others require it once two-factor authentication is on)." case strings.Contains(msg, "5.7.8"), strings.Contains(msg, "authentication failed"), strings.Contains(msg, "535"): return "\n\nThe server rejected the username or the password." case strings.Contains(msg, "must issue a starttls"): return "\n\nThe server requires encryption: set STARTTLS (usually port 587) or SSL (465)." } return "" } // 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) }