package main import ( "encoding/json" "errors" "flag" "fmt" "log" "os" "time" "github.com/descrybe/descrybe-v2/apps/api/internal/config" "github.com/descrybe/descrybe-v2/apps/api/internal/mail" "github.com/google/uuid" ) // Hook matches migrator maps/set-password-hooks.json (invite tokens for must_set_password users). type Hook struct { UserID uuid.UUID `json:"user_id"` Email string `json:"email"` CompanyID uuid.UUID `json:"company_id"` Role string `json:"role"` Token string `json:"token"` ExpiresAt time.Time `json:"expires_at"` InviteID uuid.UUID `json:"invite_id"` } func main() { hooksPath := flag.String("hooks", "", "Path to set-password-hooks.json from migrator maps-dir") dryRun := flag.Bool("dry-run", false, "Print counts without sending") delayMS := flag.Int("delay-ms", 100, "Pause between sends (SMTP rate limit)") flag.Parse() if *hooksPath == "" { log.Fatal("-hooks is required (e.g. ../../artifacts/maps/set-password-hooks.json)") } cfg, err := config.Load() if err != nil { log.Fatalf("config: %v", err) } raw, err := os.ReadFile(*hooksPath) if err != nil { log.Fatalf("read hooks: %v", err) } var hooks []Hook if err := json.Unmarshal(raw, &hooks); err != nil { log.Fatalf("parse hooks: %v", err) } mailer := mail.New(mail.Config{ Enabled: cfg.SMTPEnabled, Host: cfg.SMTPHost, Port: cfg.SMTPPort, User: cfg.SMTPUser, Password: cfg.SMTPPassword, From: cfg.SMTPFrom, }) if err := assertMailhooksReady(*dryRun, mailer.Enabled(), cfg.EmailDryRun); err != nil { log.Fatal(err) } sent, skipped, failed := 0, 0, 0 now := time.Now().UTC() for _, h := range hooks { if h.Token == "" || h.Email == "" { skipped++ continue } if !h.ExpiresAt.IsZero() && now.After(h.ExpiresAt) { skipped++ continue } msg := mail.MigratedSetPasswordMessage(cfg.WebOrigin, h.Email, h.Token) if *dryRun { log.Printf("mailhooks: dry-run subject=%q", msg.Subject) sent++ continue } if err := mailer.Send(msg); err != nil { log.Printf("mailhooks: send failed subject=%q", msg.Subject) failed++ continue } sent++ if *delayMS > 0 { time.Sleep(time.Duration(*delayMS) * time.Millisecond) } } fmt.Printf("mailhooks: sent=%d skipped=%d failed=%d smtp_enabled=%v total=%d\n", sent, skipped, failed, mailer.Enabled(), len(hooks)) if failed > 0 { os.Exit(1) } } // assertMailhooksReady fails closed for live sends when SMTP is a no-op or EMAIL_DRY_RUN is on. func assertMailhooksReady(dryRun, mailerEnabled, emailDryRun bool) error { if dryRun { return nil } if emailDryRun { return errors.New("mailhooks: email dry-run is on; pass -dry-run or disable dry-run in admin platform mail settings (or EMAIL_DRY_RUN=false)") } if !mailerEnabled { return errors.New("mailhooks: SMTP not configured; pass -dry-run or set platform mail SMTP in admin (smtp.enabled + host/from)") } return nil }