@@ -3,6 +3,8 @@ |
| 3 | 3 | package main |
| 4 | 4 | |
| 5 | 5 | import ( |
| 6 | + "crypto/sha256" |
| 7 | + "encoding/base64" |
| 6 | 8 | "errors" |
| 7 | 9 | "fmt" |
| 8 | 10 | "log/slog" |
@@ -16,6 +18,7 @@ import ( |
| 16 | 18 | "github.com/spf13/cobra" |
| 17 | 19 | |
| 18 | 20 | "github.com/tenseleyFlow/shithub/internal/auth/audit" |
| 21 | + "github.com/tenseleyFlow/shithub/internal/auth/email" |
| 19 | 22 | "github.com/tenseleyFlow/shithub/internal/infra/config" |
| 20 | 23 | "github.com/tenseleyFlow/shithub/internal/infra/db" |
| 21 | 24 | "github.com/tenseleyFlow/shithub/internal/infra/storage" |
@@ -108,6 +111,17 @@ var workerCmd = &cobra.Command{ |
| 108 | 111 | Pool: pool, Logger: logger, |
| 109 | 112 | })) |
| 110 | 113 | |
| 114 | + notifSender, _ := pickNotifEmailSender(cfg) |
| 115 | + p.Register(worker.KindNotifyFanout, jobs.NotifyFanout(jobs.NotifyFanoutDeps{ |
| 116 | + Pool: pool, |
| 117 | + Logger: logger, |
| 118 | + EmailSender: notifSender, |
| 119 | + EmailFrom: cfg.Auth.EmailFrom, |
| 120 | + SiteName: cfg.Auth.SiteName, |
| 121 | + BaseURL: cfg.Auth.BaseURL, |
| 122 | + UnsubscribeKey: notifUnsubscribeKey(cfg, logger), |
| 123 | + })) |
| 124 | + |
| 111 | 125 | return p.Run(ctx) |
| 112 | 126 | }, |
| 113 | 127 | } |
@@ -116,3 +130,66 @@ func init() { |
| 116 | 130 | workerCmd.Flags().Int("workers", 0, "Number of worker goroutines (default 4)") |
| 117 | 131 | rootCmd.AddCommand(workerCmd) |
| 118 | 132 | } |
| 133 | + |
| 134 | +// pickNotifEmailSender mirrors pickAdminEmailSender / pickEmailSender |
| 135 | +// in the web binary. Kept local to the worker so failure to construct |
| 136 | +// the sender doesn't kill the process — fan-out without email is a |
| 137 | +// supported degraded mode (inbox rows still land). |
| 138 | +func pickNotifEmailSender(cfg config.Config) (email.Sender, error) { |
| 139 | + switch cfg.Auth.EmailBackend { |
| 140 | + case "stdout": |
| 141 | + return email.NewStdoutSender(os.Stdout), nil |
| 142 | + case "smtp": |
| 143 | + return &email.SMTPSender{ |
| 144 | + Addr: cfg.Auth.SMTP.Addr, |
| 145 | + From: cfg.Auth.EmailFrom, |
| 146 | + Username: cfg.Auth.SMTP.Username, |
| 147 | + Password: cfg.Auth.SMTP.Password, |
| 148 | + }, nil |
| 149 | + case "postmark": |
| 150 | + return &email.PostmarkSender{ |
| 151 | + ServerToken: cfg.Auth.Postmark.ServerToken, |
| 152 | + From: cfg.Auth.EmailFrom, |
| 153 | + }, nil |
| 154 | + default: |
| 155 | + return nil, errors.New("worker: unknown email_backend") |
| 156 | + } |
| 157 | +} |
| 158 | + |
| 159 | +// notifUnsubscribeKey resolves the HMAC key used to sign one-click |
| 160 | +// unsubscribe URLs. Operators set Notif.UnsubscribeKeyB64 in prod. |
| 161 | +// In dev (key empty) we derive a deterministic 32-byte key from the |
| 162 | +// session secret so unsubscribe links survive process restarts |
| 163 | +// without operator action — and we log a loud warning so the |
| 164 | +// derivation can't sneak into prod by accident. |
| 165 | +func notifUnsubscribeKey(cfg config.Config, logger *slog.Logger) []byte { |
| 166 | + if cfg.Notif.UnsubscribeKeyB64 != "" { |
| 167 | + k, err := base64.StdEncoding.DecodeString(cfg.Notif.UnsubscribeKeyB64) |
| 168 | + if err == nil && len(k) >= 16 { |
| 169 | + return k |
| 170 | + } |
| 171 | + if logger != nil { |
| 172 | + logger.Warn("notif: unsubscribe_key_b64 invalid; falling back to derived key", |
| 173 | + "hint", "set Notif.UnsubscribeKeyB64 to base64-encoded 32+ random bytes") |
| 174 | + } |
| 175 | + } |
| 176 | + if cfg.Session.KeyB64 != "" { |
| 177 | + seed, err := base64.StdEncoding.DecodeString(cfg.Session.KeyB64) |
| 178 | + if err == nil && len(seed) > 0 { |
| 179 | + sum := sha256.Sum256(append([]byte("notif-unsub:"), seed...)) |
| 180 | + if logger != nil { |
| 181 | + logger.Warn("notif: deriving unsubscribe key from session secret (dev fallback)", |
| 182 | + "hint", "set Notif.UnsubscribeKeyB64 in prod") |
| 183 | + } |
| 184 | + return sum[:] |
| 185 | + } |
| 186 | + } |
| 187 | + // Last-resort static dev key — links work but anyone with source |
| 188 | + // access can mint them. Logged at WARN so operators notice in |
| 189 | + // prod logs. |
| 190 | + if logger != nil { |
| 191 | + logger.Warn("notif: no key material — using static dev key", |
| 192 | + "hint", "set Notif.UnsubscribeKeyB64 (or session.key_b64)") |
| 193 | + } |
| 194 | + return []byte("shithub-dev-unsub-static-key-32B") |
| 195 | +} |