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 == "" {
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")
}
if cfg.IMAPHost == "" {
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")
}
@ -110,13 +110,13 @@ func (c *EmailChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]str
}
addr := fmt.Sprintf("%s:%d", c.config.SMTPHost, smtpPort)
smtpUser := c.config.SMTPUser
smtpUser := c.config.SMTPUser.String()
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",
c.config.SMTPFrom, to, subject, msg.Content)
c.config.SMTPFrom.String(), to, subject, msg.Content)
var auth smtp.Auth
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)
}
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
}
} 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)
}
}
@ -215,7 +215,7 @@ func (c *EmailChannel) pollIMAP() {
}
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})
return
}

View file

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

View file

@ -603,14 +603,14 @@ type EmailConfig struct {
// SMTP (outbound)
SMTPHost string `json:"smtp_host" yaml:"-" env:"PICOCLAW_CHANNELS_EMAIL_SMTP_HOST"`
SMTPPort int `json:"smtp_port" yaml:"-" env:"PICOCLAW_CHANNELS_EMAIL_SMTP_PORT"`
SMTPFrom string `json:"smtp_from" yaml:"-" env:"PICOCLAW_CHANNELS_EMAIL_SMTP_FROM"`
SMTPUser string `json:"smtp_user" yaml:"-" env:"PICOCLAW_CHANNELS_EMAIL_SMTP_USER"`
SMTPFrom SecureString `json:"smtp_from,omitzero" yaml:"smtp_from,omitempty" env:"PICOCLAW_CHANNELS_EMAIL_SMTP_FROM"`
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"`
DefaultSubject string `json:"default_subject" yaml:"-" env:"PICOCLAW_CHANNELS_EMAIL_DEFAULT_SUBJECT"`
// IMAP (inbound)
IMAPHost string `json:"imap_host" yaml:"-" env:"PICOCLAW_CHANNELS_EMAIL_IMAP_HOST"`
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"`
PollIntervalSecs int `json:"poll_interval_secs" yaml:"-" env:"PICOCLAW_CHANNELS_EMAIL_POLL_INTERVAL_SECS"`
// Common

View file

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