A live install passed the SMTP setup screen and then failed every one-time
code with a bare `internal error`. Four separate defects had to line up for
that, and each is fixed here.
The relay was configured with `from = noreply@<domain-A>` on an account
authenticated as `<user>@<domain-B>`. Providers that validate sender identity
— Fastmail among them — answer MAIL FROM with an unconditional 250 and only
refuse at end-of-DATA. Ping stopped at NOOP, so it never saw the refusal: the
wizard reported success, wrote the config, rolled felis-api, and every OTP
afterwards died at w.Close().
Ping now runs the same transaction a real code takes — connect, (STARTTLS,)
AUTH, MAIL FROM, RCPT TO, DATA — delivering one self-test message to the From
address, and SendOTP and Ping share deliver() so the check cannot drift from
the thing it checks. The self-test recipient cannot cause a false negative:
an authenticated submission relay accepts RCPT for any destination by
definition, while the sender identity it does validate is exactly what we
want tested. The setup screen now says a message will be sent, names the
address it went to, and warns that From must be an address the account is
allowed to send as.
A relay refusal also answered 500 `internal`, which reads as a broken panel
and sends the operator hunting through handler code instead of their [smtp]
block. It is now 502 `mail_undeliverable`, mapped inside deliverOTP so all
four doors that mail a code (onboarding, email login, op-login, migrate
step-up) answer alike. The relay's own text stays out of the response — it
can name the SMTP account, and these routes are reachable by any signed-in
player — and goes to the log instead.
writeError logged nothing when it collapsed an unmapped error to 500, so an
operator holding an `internal error` had nothing to grep for and diagnosis
degraded into guessing against a live install. It now logs the method, path,
wrapped chain and the same request_id the caller is shown.
Finally, write_felis_toml regenerated the config wholesale and never emitted
[smtp], so re-running the installer — the documented way to update felis-api —
silently erased a working relay and reverted OTP delivery to the no-Mailer
path, logging codes instead of sending them. It now carries the block forward,
cached on first read because the host toml is clobbered before the pod toml is
written. Same defect family as the root_domain loss fixed in ecbeb20: a
generated file holding a hand-set value with no carry-forward.
Tests cover the case a MAIL FROM probe cannot see: a fake relay that answers
250 to MAIL FROM and 550 at end-of-DATA must fail both Ping and SendOTP, and
the 502 must carry a distinct machine code without leaking the relay's text.
202 lines
7.8 KiB
Go
202 lines
7.8 KiB
Go
// Package mail is the SMTP implementation of the api.OTPMailer seam: it
|
|
// delivers the email one-time codes the passwordless doors mint (onboarding,
|
|
// email login, op-login) through the relay configured in felis.toml [smtp].
|
|
// It is deliberately tiny — one message shape, stdlib net/smtp — because the
|
|
// only mail Felis ever sends is a six-digit code.
|
|
//
|
|
// TLS posture: port 465 dials implicit TLS; any other port dials plaintext and
|
|
// upgrades via STARTTLS when the relay advertises it. AUTH is attempted only
|
|
// when a username is configured, and net/smtp's PlainAuth itself refuses to
|
|
// send credentials over an unencrypted connection — a relay that offers no
|
|
// TLS can carry unauthenticated mail but can never be handed the password.
|
|
package mail
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"fmt"
|
|
"mime"
|
|
"net"
|
|
"net/smtp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// sendTimeout bounds one whole SMTP conversation when the caller's context
|
|
// carries no deadline of its own; codes are time-critical (the player is
|
|
// staring at a spinner), so a wedged relay must fail fast, not hang a handler.
|
|
const sendTimeout = 30 * time.Second
|
|
|
|
// SMTP delivers one-time codes through a single configured relay. Fields
|
|
// mirror felis.toml [smtp]; Password is the resolved secret (read from the
|
|
// env var password_ref names), never the ref itself.
|
|
type SMTP struct {
|
|
Host string
|
|
Port int
|
|
From string
|
|
Username string
|
|
Password string
|
|
}
|
|
|
|
// SendOTP mails code to email as a small bilingual plain-text message. It is
|
|
// the api.OTPMailer implementation felis-api wires when [smtp] is configured.
|
|
func (s *SMTP) SendOTP(ctx context.Context, email, code string) error {
|
|
c, err := s.connect(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer c.Close()
|
|
if err := s.deliver(c, email, message(s.From, email, code, time.Now())); err != nil {
|
|
return err
|
|
}
|
|
return c.Quit()
|
|
}
|
|
|
|
// Ping proves the configured relay will actually ACCEPT mail from this sender,
|
|
// by running a complete transaction — connect, (STARTTLS,) AUTH, MAIL FROM,
|
|
// RCPT TO, DATA — and delivering a short self-test message to From itself. The
|
|
// setup wizard runs it before writing anything, so a bad relay fails at the
|
|
// keyboard instead of at the first code a player is waiting on.
|
|
//
|
|
// It really does send that one message, and it has to: a probe that stops at
|
|
// NOOP (or even at MAIL FROM) proves nothing about deliverability, because
|
|
// relays which validate sender identity answer MAIL FROM with an unconditional
|
|
// 250 and defer the verdict to end-of-DATA. Fastmail does exactly that, and a
|
|
// NOOP-only Ping green-lit a From on a domain the account could not send as —
|
|
// every OTP after it died at w.Close() with the wizard reporting success.
|
|
//
|
|
// Addressing the self-test to From cannot cause a false negative: this is an
|
|
// authenticated submission relay, whose job is to accept RCPT for any
|
|
// destination, so the recipient is never what a refusal is about — while the
|
|
// sender identity, which is, still gets checked. It also puts the proof
|
|
// somewhere the operator can go look at it.
|
|
func (s *SMTP) Ping(ctx context.Context) error {
|
|
c, err := s.connect(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer c.Close()
|
|
if err := s.deliver(c, s.From, selfTest(s.From, time.Now())); err != nil {
|
|
return err
|
|
}
|
|
return c.Quit()
|
|
}
|
|
|
|
// deliver runs one MAIL FROM → RCPT TO → DATA transaction on an established
|
|
// client. SendOTP and Ping share it so the wizard's check exercises the exact
|
|
// path a player's code takes — a check that skips a step is a check that can
|
|
// pass while the real send fails.
|
|
func (s *SMTP) deliver(c *smtp.Client, to string, msg []byte) error {
|
|
if err := c.Mail(s.From); err != nil {
|
|
return fmt.Errorf("smtp: MAIL FROM %s: %w", s.From, err)
|
|
}
|
|
if err := c.Rcpt(to); err != nil {
|
|
return fmt.Errorf("smtp: RCPT TO: %w", err)
|
|
}
|
|
w, err := c.Data()
|
|
if err != nil {
|
|
return fmt.Errorf("smtp: DATA: %w", err)
|
|
}
|
|
if _, err := w.Write(msg); err != nil {
|
|
return fmt.Errorf("smtp: write message: %w", err)
|
|
}
|
|
// End-of-DATA is where a relay renders its real verdict on the sender, so
|
|
// this error names From: "550 …" here almost always means the account is
|
|
// not allowed to send as that address.
|
|
if err := w.Close(); err != nil {
|
|
return fmt.Errorf("smtp: relay refused mail from %s: %w", s.From, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// connect dials the relay, upgrades to TLS per the port's posture, and
|
|
// authenticates when a username is configured. The whole conversation shares
|
|
// one deadline (the context's, else sendTimeout from now).
|
|
func (s *SMTP) connect(ctx context.Context) (*smtp.Client, error) {
|
|
addr := net.JoinHostPort(s.Host, strconv.Itoa(s.Port))
|
|
deadline, ok := ctx.Deadline()
|
|
if !ok {
|
|
deadline = time.Now().Add(sendTimeout)
|
|
}
|
|
dialer := &net.Dialer{Deadline: deadline}
|
|
|
|
var conn net.Conn
|
|
var err error
|
|
if s.Port == 465 {
|
|
// Implicit TLS: the socket is TLS from byte zero (smtps submission).
|
|
conn, err = (&tls.Dialer{NetDialer: dialer, Config: &tls.Config{ServerName: s.Host}}).DialContext(ctx, "tcp", addr)
|
|
} else {
|
|
conn, err = dialer.DialContext(ctx, "tcp", addr)
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("smtp: dial %s: %w", addr, err)
|
|
}
|
|
_ = conn.SetDeadline(deadline)
|
|
|
|
c, err := smtp.NewClient(conn, s.Host)
|
|
if err != nil {
|
|
conn.Close()
|
|
return nil, fmt.Errorf("smtp: handshake %s: %w", addr, err)
|
|
}
|
|
if s.Port != 465 {
|
|
if ok, _ := c.Extension("STARTTLS"); ok {
|
|
if err := c.StartTLS(&tls.Config{ServerName: s.Host}); err != nil {
|
|
c.Close()
|
|
return nil, fmt.Errorf("smtp: starttls: %w", err)
|
|
}
|
|
}
|
|
}
|
|
if s.Username != "" {
|
|
// PlainAuth refuses an unencrypted connection on its own, so the password
|
|
// can never leak to a relay that failed to negotiate TLS above.
|
|
if err := c.Auth(smtp.PlainAuth("", s.Username, s.Password, s.Host)); err != nil {
|
|
c.Close()
|
|
return nil, fmt.Errorf("smtp: auth as %s: %w", s.Username, err)
|
|
}
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
// headers renders the RFC 5322 header block every Felis message shares: CRLF
|
|
// throughout, the subject Q-encoded because both subjects carry non-ASCII, and
|
|
// the blank line that ends the block.
|
|
func headers(from, to, subject string, now time.Time) string {
|
|
var b strings.Builder
|
|
b.WriteString("From: " + from + "\r\n")
|
|
b.WriteString("To: " + to + "\r\n")
|
|
b.WriteString("Subject: " + mime.QEncoding.Encode("utf-8", subject) + "\r\n")
|
|
b.WriteString("Date: " + now.Format(time.RFC1123Z) + "\r\n")
|
|
b.WriteString("MIME-Version: 1.0\r\n")
|
|
b.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
|
|
b.WriteString("\r\n")
|
|
return b.String()
|
|
}
|
|
|
|
// message renders the one mail players receive: a short bilingual plain-text
|
|
// body carrying the code. Split out from SendOTP so the shape is testable
|
|
// without a relay.
|
|
func message(from, to, code string, now time.Time) []byte {
|
|
var b strings.Builder
|
|
b.WriteString(headers(from, to, "Felis 验证码 · verification code", now))
|
|
b.WriteString("Your Felis verification code / Felis 验证码:\r\n")
|
|
b.WriteString("\r\n")
|
|
b.WriteString(" " + code + "\r\n")
|
|
b.WriteString("\r\n")
|
|
b.WriteString("If you didn't request this, ignore this message. / 若非本人操作,请忽略此邮件。\r\n")
|
|
return []byte(b.String())
|
|
}
|
|
|
|
// selfTest renders the message Ping delivers to the sender itself. It carries
|
|
// no code and says why it arrived, so an operator who finds it in the inbox
|
|
// reads it as the wizard's proof of delivery rather than a stray OTP.
|
|
func selfTest(from string, now time.Time) []byte {
|
|
var b strings.Builder
|
|
b.WriteString(headers(from, from, "Felis SMTP 自检 · relay self-test", now))
|
|
b.WriteString("Felis accepted this relay because it accepted this message.\r\n")
|
|
b.WriteString("Felis 已确认该邮件中继可用:本邮件即为投递证明。\r\n")
|
|
b.WriteString("\r\n")
|
|
b.WriteString("Sent by `felis setup` when the SMTP relay was configured. / 由 `felis setup` 配置 SMTP 时发出。\r\n")
|
|
return []byte(b.String())
|
|
}
|