TCI meters: the S-meter came only from RX_SMETER and the transmit meters from TX_POWER / TX_SWR — commands ExpertSDR3 does not send. The protocol's answer is a subscription (RX_SENSORS_ENABLE / TX_SENSORS_ENABLE, §4.4 of the TCI PDF), after which the radio pushes RX_CHANNEL_SENSORS and TX_SENSORS. Nobody had asked, so the console's meters sat empty in RX and in TX while everything else worked. Cluster: the held-spot counter looked for the row it froze on. A station spotted again REPLACES its row, so that row vanishes in the ordinary course of things and the count fell through to 'everything is new' — 4, 5, then 500. Counted by timestamp now, which survives both the replacement and the ring buffer. SMTP: '535 5.7.139 basic authentication is disabled' is a policy, not a typo. The message now says so, and says what actually helps, without claiming a policy when the server simply rejected the password.
149 lines
5.1 KiB
Go
149 lines
5.1 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"
|
|
"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)
|
|
}
|