fix(email): support env:// refs in smtp_from/smtp_user/imap_user and preserve email config through v0 migration

- Change SMTPFrom, SMTPUser, IMAPUser from plain string to SecureString
  so env:// references are resolved at runtime (previously passed literally
  to SMTP/IMAP, causing auth failures)
- Add Email field to channelsConfigV0 and ToChannelsConfig() so the email
  block is not silently dropped when loading a version-0 config (the
  migration was resetting enabled=false regardless of config contents)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
github-actions[bot] 2026-04-10 20:12:45 +02:00
parent 024282be26
commit 8eec398c9f
4 changed files with 17 additions and 15 deletions

View file

@ -32,13 +32,13 @@ func NewEmailChannel(cfg config.EmailConfig, messageBus *bus.MessageBus) (*Email
if cfg.SMTPHost == "" { if cfg.SMTPHost == "" {
return nil, fmt.Errorf("email smtp_host is required") return nil, fmt.Errorf("email smtp_host is required")
} }
if cfg.SMTPFrom == "" { if cfg.SMTPFrom.String() == "" {
return nil, fmt.Errorf("email smtp_from is required") return nil, fmt.Errorf("email smtp_from is required")
} }
if cfg.IMAPHost == "" { if cfg.IMAPHost == "" {
return nil, fmt.Errorf("email imap_host is required") return nil, fmt.Errorf("email imap_host is required")
} }
if cfg.IMAPUser == "" { if cfg.IMAPUser.String() == "" {
return nil, fmt.Errorf("email imap_user is required") return nil, fmt.Errorf("email imap_user is required")
} }
@ -110,13 +110,13 @@ func (c *EmailChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]str
} }
addr := fmt.Sprintf("%s:%d", c.config.SMTPHost, smtpPort) addr := fmt.Sprintf("%s:%d", c.config.SMTPHost, smtpPort)
smtpUser := c.config.SMTPUser smtpUser := c.config.SMTPUser.String()
if smtpUser == "" { if smtpUser == "" {
smtpUser = c.config.SMTPFrom smtpUser = c.config.SMTPFrom.String()
} }
body := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\n\r\n%s", body := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\n\r\n%s",
c.config.SMTPFrom, to, subject, msg.Content) c.config.SMTPFrom.String(), to, subject, msg.Content)
var auth smtp.Auth var auth smtp.Auth
if c.config.SMTPPassword.String() != "" { if c.config.SMTPPassword.String() != "" {
@ -135,11 +135,11 @@ func (c *EmailChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]str
return nil, fmt.Errorf("smtp new client: %w", channels.ErrTemporary) return nil, fmt.Errorf("smtp new client: %w", channels.ErrTemporary)
} }
defer client.Close() defer client.Close()
if err := sendViaSMTPClient(client, auth, c.config.SMTPFrom, to, []byte(body)); err != nil { if err := sendViaSMTPClient(client, auth, c.config.SMTPFrom.String(), to, []byte(body)); err != nil {
return nil, err return nil, err
} }
} else { } else {
if err := smtp.SendMail(addr, auth, c.config.SMTPFrom, []string{to}, []byte(body)); err != nil { if err := smtp.SendMail(addr, auth, c.config.SMTPFrom.String(), []string{to}, []byte(body)); err != nil {
return nil, fmt.Errorf("smtp send: %w: %w", err, channels.ErrTemporary) return nil, fmt.Errorf("smtp send: %w: %w", err, channels.ErrTemporary)
} }
} }
@ -215,7 +215,7 @@ func (c *EmailChannel) pollIMAP() {
} }
defer client.Close() defer client.Close()
if err = client.Login(c.config.IMAPUser, c.config.IMAPPassword.String()).Wait(); err != nil { if err = client.Login(c.config.IMAPUser.String(), c.config.IMAPPassword.String()).Wait(); err != nil {
logger.WarnCF("email", "IMAP login failed", map[string]any{"err": err}) logger.WarnCF("email", "IMAP login failed", map[string]any{"err": err})
return return
} }

View file

@ -15,9 +15,9 @@ func TestNewEmailChannel(t *testing.T) {
validCfg := config.EmailConfig{ validCfg := config.EmailConfig{
SMTPHost: "smtp.example.com", SMTPHost: "smtp.example.com",
SMTPFrom: "bot@example.com", SMTPFrom: *config.NewSecureString("bot@example.com"),
IMAPHost: "imap.example.com", IMAPHost: "imap.example.com",
IMAPUser: "bot@example.com", IMAPUser: *config.NewSecureString("bot@example.com"),
} }
t.Run("missing smtp_host", func(t *testing.T) { t.Run("missing smtp_host", func(t *testing.T) {
@ -31,7 +31,7 @@ func TestNewEmailChannel(t *testing.T) {
t.Run("missing smtp_from", func(t *testing.T) { t.Run("missing smtp_from", func(t *testing.T) {
cfg := validCfg cfg := validCfg
cfg.SMTPFrom = "" cfg.SMTPFrom = *config.NewSecureString("")
_, err := NewEmailChannel(cfg, msgBus) _, err := NewEmailChannel(cfg, msgBus)
if err == nil { if err == nil {
t.Error("expected error for missing smtp_from, got nil") t.Error("expected error for missing smtp_from, got nil")
@ -49,7 +49,7 @@ func TestNewEmailChannel(t *testing.T) {
t.Run("missing imap_user", func(t *testing.T) { t.Run("missing imap_user", func(t *testing.T) {
cfg := validCfg cfg := validCfg
cfg.IMAPUser = "" cfg.IMAPUser = *config.NewSecureString("")
_, err := NewEmailChannel(cfg, msgBus) _, err := NewEmailChannel(cfg, msgBus)
if err == nil { if err == nil {
t.Error("expected error for missing imap_user, got nil") t.Error("expected error for missing imap_user, got nil")

View file

@ -603,14 +603,14 @@ type EmailConfig struct {
// SMTP (outbound) // SMTP (outbound)
SMTPHost string `json:"smtp_host" yaml:"-" env:"PICOCLAW_CHANNELS_EMAIL_SMTP_HOST"` SMTPHost string `json:"smtp_host" yaml:"-" env:"PICOCLAW_CHANNELS_EMAIL_SMTP_HOST"`
SMTPPort int `json:"smtp_port" yaml:"-" env:"PICOCLAW_CHANNELS_EMAIL_SMTP_PORT"` SMTPPort int `json:"smtp_port" yaml:"-" env:"PICOCLAW_CHANNELS_EMAIL_SMTP_PORT"`
SMTPFrom string `json:"smtp_from" yaml:"-" env:"PICOCLAW_CHANNELS_EMAIL_SMTP_FROM"` SMTPFrom SecureString `json:"smtp_from,omitzero" yaml:"smtp_from,omitempty" env:"PICOCLAW_CHANNELS_EMAIL_SMTP_FROM"`
SMTPUser string `json:"smtp_user" yaml:"-" env:"PICOCLAW_CHANNELS_EMAIL_SMTP_USER"` SMTPUser SecureString `json:"smtp_user,omitzero" yaml:"smtp_user,omitempty" env:"PICOCLAW_CHANNELS_EMAIL_SMTP_USER"`
SMTPPassword SecureString `json:"smtp_password,omitzero" yaml:"smtp_password,omitempty" env:"PICOCLAW_CHANNELS_EMAIL_SMTP_PASSWORD"` SMTPPassword SecureString `json:"smtp_password,omitzero" yaml:"smtp_password,omitempty" env:"PICOCLAW_CHANNELS_EMAIL_SMTP_PASSWORD"`
DefaultSubject string `json:"default_subject" yaml:"-" env:"PICOCLAW_CHANNELS_EMAIL_DEFAULT_SUBJECT"` DefaultSubject string `json:"default_subject" yaml:"-" env:"PICOCLAW_CHANNELS_EMAIL_DEFAULT_SUBJECT"`
// IMAP (inbound) // IMAP (inbound)
IMAPHost string `json:"imap_host" yaml:"-" env:"PICOCLAW_CHANNELS_EMAIL_IMAP_HOST"` IMAPHost string `json:"imap_host" yaml:"-" env:"PICOCLAW_CHANNELS_EMAIL_IMAP_HOST"`
IMAPPort int `json:"imap_port" yaml:"-" env:"PICOCLAW_CHANNELS_EMAIL_IMAP_PORT"` IMAPPort int `json:"imap_port" yaml:"-" env:"PICOCLAW_CHANNELS_EMAIL_IMAP_PORT"`
IMAPUser string `json:"imap_user" yaml:"-" env:"PICOCLAW_CHANNELS_EMAIL_IMAP_USER"` IMAPUser SecureString `json:"imap_user,omitzero" yaml:"imap_user,omitempty" env:"PICOCLAW_CHANNELS_EMAIL_IMAP_USER"`
IMAPPassword SecureString `json:"imap_password,omitzero" yaml:"imap_password,omitempty" env:"PICOCLAW_CHANNELS_EMAIL_IMAP_PASSWORD"` IMAPPassword SecureString `json:"imap_password,omitzero" yaml:"imap_password,omitempty" env:"PICOCLAW_CHANNELS_EMAIL_IMAP_PASSWORD"`
PollIntervalSecs int `json:"poll_interval_secs" yaml:"-" env:"PICOCLAW_CHANNELS_EMAIL_POLL_INTERVAL_SECS"` PollIntervalSecs int `json:"poll_interval_secs" yaml:"-" env:"PICOCLAW_CHANNELS_EMAIL_POLL_INTERVAL_SECS"`
// Common // Common

View file

@ -100,6 +100,7 @@ type channelsConfigV0 struct {
WeCom wecomConfigV0 `json:"wecom" envPrefix:"PICOCLAW_CHANNELS_WECOM_"` WeCom wecomConfigV0 `json:"wecom" envPrefix:"PICOCLAW_CHANNELS_WECOM_"`
Pico picoConfigV0 `json:"pico"` Pico picoConfigV0 `json:"pico"`
IRC ircConfigV0 `json:"irc"` IRC ircConfigV0 `json:"irc"`
Email EmailConfig `json:"email"`
} }
func (v *channelsConfigV0) ToChannelsConfig() ChannelsConfig { func (v *channelsConfigV0) ToChannelsConfig() ChannelsConfig {
@ -134,6 +135,7 @@ func (v *channelsConfigV0) ToChannelsConfig() ChannelsConfig {
WeCom: wecom, WeCom: wecom,
Pico: pico, Pico: pico,
IRC: irc, IRC: irc,
Email: v.Email,
} }
} }