From 54666030eb3c648e7fc37c2ff4cc62446ecab25e Mon Sep 17 00:00:00 2001 From: Caleb Bae Date: Fri, 19 Jun 2026 12:35:54 -0700 Subject: [PATCH 1/2] feat: SMTP & sendgrid plumbing --- cmd/api/api.go | 12 +----- cmd/api/main.go | 22 +++++++--- go.mod | 2 +- internal/mailer/mailer.go | 46 +++++++++++++++++++- internal/mailer/sendgrid.go | 12 ++++-- internal/mailer/smtp.go | 84 +++++++++++++++++++++++++++++++++++++ 6 files changed, 155 insertions(+), 23 deletions(-) create mode 100644 internal/mailer/smtp.go diff --git a/cmd/api/api.go b/cmd/api/api.go index 67e2f729..d28b6765 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -39,7 +39,7 @@ type config struct { appURL string frontendURL string hackathonTimeZone string - mail mailConfig + mail mailer.Config gcs gcsConfig auth authConfig rateLimiter ratelimiter.Config @@ -71,16 +71,6 @@ type basicConfig struct { user string pass string } - -type mailConfig struct { - sendGrid sendGridConfig - fromEmail string -} - -type sendGridConfig struct { - apiKey string -} - type gcsConfig struct { bucketName string } diff --git a/cmd/api/main.go b/cmd/api/main.go index 0a5caccf..b7f04e58 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -57,11 +57,18 @@ func main() { maxIdleTime: env.GetString("DB_MAX_IDLE_TIME", "15m"), }, env: env.GetString("ENV", "development"), - mail: mailConfig{ - sendGrid: sendGridConfig{ - apiKey: env.GetString("SENDGRID_API_KEY", ""), + mail: mailer.Config{ + SendGrid: mailer.SendGridConfig{ + APIKey: env.GetString("SENDGRID_API_KEY", ""), }, - fromEmail: env.GetString("MAIL_FROM", "noreply@hackportal.com"), + SMTP: mailer.SMTPConfig{ + Host: env.GetString("EMAIL_HOST", ""), + Port: env.GetInt("EMAIL_PORT", 587), + Username: env.GetString("EMAIL_USERNAME", ""), + Password: env.GetString("EMAIL_PASSWORD", ""), + }, + FromEmail: env.GetString("EMAIL_FROM", "noreply@example.com"), + FromName: env.GetString("EMAIL_FROM_NAME", mailer.FromName), }, gcs: gcsConfig{ bucketName: env.GetString("GCS_BUCKET_NAME", ""), @@ -137,8 +144,11 @@ func main() { } logger.Info("supertokens initialized") - // Init mailer - mailClient := mailer.NewSendGrid(cfg.mail.sendGrid.apiKey, cfg.mail.fromEmail) + // Init mailer — picks provider from .env SMTP or SendGrid, at least one is required + mailClient, err := mailer.New(cfg.mail) + if err != nil { + logger.Fatal("failed to initialize mailer", zap.Error(err)) + } // Init GCS (optional in local/dev) var gcsClient gcs.Client diff --git a/go.mod b/go.mod index 2a5a3213..acff74f2 100644 --- a/go.mod +++ b/go.mod @@ -103,7 +103,7 @@ require ( golang.org/x/text v0.33.0 // indirect golang.org/x/tools v0.40.0 // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect - gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df // indirect + gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df gopkg.in/h2non/gock.v1 v1.1.2 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/internal/mailer/mailer.go b/internal/mailer/mailer.go index f6b62ad6..04da9718 100644 --- a/internal/mailer/mailer.go +++ b/internal/mailer/mailer.go @@ -1,6 +1,9 @@ package mailer -import "embed" +import ( + "embed" + "fmt" +) const ( FromName = "HackUTD" @@ -12,3 +15,44 @@ var FS embed.FS type Client interface { SendQREmail(toEmail, toName, userID string) error } + +type Config struct { + FromEmail string + FromName string + SendGrid SendGridConfig + SMTP SMTPConfig +} + +type SendGridConfig struct { + APIKey string +} + +type SMTPConfig struct { + Host string + Port int + Username string + Password string +} + +// New selects a mailer provider based on cfg: SMTP when EMAIL_HOST is set, +// otherwise SendGrid when SENDGRID_API_KEY is set. At least one is required. +func New(cfg Config) (Client, error) { + switch { + case cfg.SMTP.Host != "": + if cfg.SMTP.Username == "" || cfg.SMTP.Password == "" { + return nil, fmt.Errorf("EMAIL_HOST is set but EMAIL_USERNAME and EMAIL_PASSWORD are required for SMTP") + } + return NewSMTP( + cfg.SMTP.Host, + cfg.SMTP.Port, + cfg.SMTP.Username, + cfg.SMTP.Password, + cfg.FromEmail, + cfg.FromName, + ), nil + case cfg.SendGrid.APIKey != "": + return NewSendGrid(cfg.SendGrid.APIKey, cfg.FromEmail, cfg.FromName), nil + default: + return nil, fmt.Errorf("no mailer configured: set SMTP (EMAIL_HOST, EMAIL_USERNAME, EMAIL_PASSWORD) or SENDGRID_API_KEY") + } +} diff --git a/internal/mailer/sendgrid.go b/internal/mailer/sendgrid.go index 4ae97108..7db8bb79 100644 --- a/internal/mailer/sendgrid.go +++ b/internal/mailer/sendgrid.go @@ -13,16 +13,20 @@ import ( type SendGridMailer struct { fromEmail string - apiKey string + fromName string client *sendgrid.Client } -func NewSendGrid(apiKey, fromEmail string) *SendGridMailer { +func NewSendGrid(apiKey, fromEmail, fromName string) *SendGridMailer { client := sendgrid.NewSendClient(apiKey) + if fromName == "" { + fromName = FromName + } + return &SendGridMailer{ fromEmail: fromEmail, - apiKey: apiKey, + fromName: fromName, client: client, } } @@ -51,7 +55,7 @@ func (m *SendGridMailer) SendQREmail(toEmail, toName, userID string) error { return fmt.Errorf("executing email template: %w", err) } - from := mail.NewEmail(FromName, m.fromEmail) + from := mail.NewEmail(m.fromName, m.fromEmail) to := mail.NewEmail(toName, toEmail) message := mail.NewV3Mail() diff --git a/internal/mailer/smtp.go b/internal/mailer/smtp.go new file mode 100644 index 00000000..2c04e610 --- /dev/null +++ b/internal/mailer/smtp.go @@ -0,0 +1,84 @@ +package mailer + +import ( + "bytes" + "fmt" + "html/template" + "io" + + qrcode "github.com/skip2/go-qrcode" + gomail "gopkg.in/gomail.v2" +) + +type SMTPMailer struct { + host string + port int + username string + password string + fromEmail string + fromName string +} + +func NewSMTP(host string, port int, username, password, fromEmail, fromName string) *SMTPMailer { + if port == 0 { + port = 587 + } + if fromEmail == "" { + fromEmail = username + } + if fromName == "" { + fromName = FromName + } + + return &SMTPMailer{ + host: host, + port: port, + username: username, + password: password, + fromEmail: fromEmail, + fromName: fromName, + } +} + +func (m *SMTPMailer) SendQREmail(toEmail, toName, userID string) error { + qrPNG, err := qrcode.Encode(userID, qrcode.Medium, 256) + if err != nil { + return fmt.Errorf("generating QR code: %w", err) + } + + tmplData, err := FS.ReadFile("template/qr_email.html") + if err != nil { + return fmt.Errorf("reading email template: %w", err) + } + + tmpl, err := template.New("qr_email").Parse(string(tmplData)) + if err != nil { + return fmt.Errorf("parsing email template: %w", err) + } + + var htmlBody bytes.Buffer + if err := tmpl.Execute(&htmlBody, map[string]string{"Name": toName}); err != nil { + return fmt.Errorf("executing email template: %w", err) + } + + msg := gomail.NewMessage() + msg.SetAddressHeader("From", m.fromEmail, m.fromName) + msg.SetAddressHeader("To", toEmail, toName) + msg.SetHeader("Subject", "Your HackUTD QR Code") + msg.SetBody("text/html", htmlBody.String()) + msg.Attach( + "hackutd-qrcode.png", + gomail.SetCopyFunc(func(w io.Writer) error { + _, err := w.Write(qrPNG) + return err + }), + gomail.SetHeader(map[string][]string{"Content-Type": {"image/png"}}), + ) + + dialer := gomail.NewDialer(m.host, m.port, m.username, m.password) + if err := dialer.DialAndSend(msg); err != nil { + return fmt.Errorf("sending email: %w", err) + } + + return nil +} From 697c3b2a16b7a0e111bbaae5606a61685265c3d2 Mon Sep 17 00:00:00 2001 From: Caleb Bae Date: Sun, 21 Jun 2026 12:15:40 -0700 Subject: [PATCH 2/2] fix: update custom commands --- .claude/commands/ci-audit.md | 32 +++++++++++++++++++++++++------- .claude/commands/create-task.md | 5 +++++ 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/.claude/commands/ci-audit.md b/.claude/commands/ci-audit.md index 327db0af..5e56351a 100644 --- a/.claude/commands/ci-audit.md +++ b/.claude/commands/ci-audit.md @@ -1,14 +1,30 @@ -6s -Run npm run build +--- +description: Run all CI audit checks locally to catch issues before pushing. Go through each check sequentially and report results. Fix any issues you find automatically. +model: sonnet +--- -> web@0.0.0 build -> tsc -b && vite build +Run the CI audit checks locally to catch issues before pushing. Only run the checks for the parts of the codebase that actually changed, go through each relevant check sequentially, report results, and fix any issues you find automatically. -Error: src/pages/admin/assigned/components/ApplicationDetailsPanel.tsx(57,7): error TS2722: Cannot invoke an object which is possibly 'undefined'. -Error: Process completed with exit code 2.Run all CI audit checks locally to catch issues before pushing. Go through each check sequentially and report results. Fix any issues you find automatically. +## Determine what changed + +First, figure out which sections have changes so you can skip irrelevant checks: + +1. Run `git status --porcelain` and `git diff --name-only HEAD` to list modified, staged, and untracked files (include both committed-but-unpushed and working-tree changes). +2. Classify the changed paths: + - **Backend changes** — any changed file ending in `.go`, plus `go.mod`/`go.sum`. + - **Frontend changes** — any changed file under `client/web/`. +3. Decide which sections to run: + - Backend changes present → run the **Backend checks**. + - Frontend changes present → run the **Frontend checks**. + - Both present → run both sections. + - Neither (e.g. only docs/config) → report that there's nothing to audit and stop. + +State up front which sections you're running and why (based on the detected changes). ## Backend checks (from repo root) +Run only if backend changes were detected. + 1. **gofmt** — Run `gofmt -l .` and verify no files are unformatted. If any are, run `gofmt -w .` to fix them. 2. **go mod verify** — Run `go mod verify`. 3. **go build** — Run `go build -v ./...`. @@ -17,6 +33,8 @@ Error: Process completed with exit code 2.Run all CI audit checks locally to cat ## Frontend checks (from `client/web/`) +Run only if frontend changes were detected. + 6. **Format check** — Run `npm run format:check`. If it fails, run `npm run format` to fix, then re-check. 7. **Lint** — Run `npm run lint`. If it fails, report the errors and attempt to fix them. 8. **Build** — Run `npm run build` (includes TypeScript type checking). @@ -24,4 +42,4 @@ Error: Process completed with exit code 2.Run all CI audit checks locally to cat ## Output -After all checks complete, print a summary table showing each check's pass/fail status. If any check failed and could not be auto-fixed, explain what needs to be done. +After all relevant checks complete, print a summary table showing each check's pass/fail status. Mark skipped sections as **skipped (no changes)**. If any check failed and could not be auto-fixed, explain what needs to be done. diff --git a/.claude/commands/create-task.md b/.claude/commands/create-task.md index 496b1d7d..67077c6e 100644 --- a/.claude/commands/create-task.md +++ b/.claude/commands/create-task.md @@ -1,3 +1,8 @@ +--- +description: Create a new task in both ClickUp (Tech list) and GitHub Issues, then link them together. +model: opus +--- + # Create Task Create a new task in both ClickUp (Tech list) and GitHub Issues, then link them together.