diff --git a/.github/workflows/dev-build-deploy-email-worker.yml b/.github/workflows/dev-build-deploy-email-worker.yml new file mode 100644 index 00000000..dd75e3fd --- /dev/null +++ b/.github/workflows/dev-build-deploy-email-worker.yml @@ -0,0 +1,92 @@ +name: Deploy Email Worker to Development + +on: + push: + branches: + - dev + paths: + - 'apps/api/**' + - 'infra/docker-compose.dev.yml' + workflow_dispatch: + +permissions: + contents: read + packages: write + +concurrency: + group: dev-deploy + cancel-in-progress: true + +jobs: + build-and-push: + name: Build and Push Docker Image to GHCR + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up QEMU for cross-platform builds + uses: docker/setup-qemu-action@v2 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push multi-arch image + run: | + docker buildx build \ + --platform linux/amd64,linux/arm64 \ + --push \ + -t ghcr.io/${{ github.repository_owner }}/core-email-worker:dev \ + ./apps/api/cmd/email_worker + + run-migrations: + name: Run Goose Migrations + runs-on: ubuntu-latest + needs: build-and-push + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Goose + run: | + curl -fsSL https://raw.githubusercontent.com/pressly/goose/master/install.sh | sh + + - name: Run migrations + run: | + goose -dir ./apps/api/internal/db/migrations postgres "${{ secrets.DEV_DB_URL }}" up + + deploy: + name: Deploy to Development Server + runs-on: ubuntu-latest + needs: [build-and-push, run-migrations] + + steps: + - name: SSH proxy commmand + uses: appleboy/ssh-action@v1 + with: + host: ${{ secrets.SSH_HOST_SWAMPHACKS }} + username: ${{ secrets.SSH_USERNAME_SWAMPHACKS }} + key: ${{ secrets.SSH_KEY_SWAMPHACKS }} + port: ${{ secrets.SSH_PORT_SWAMPHACKS }} + proxy_host: ${{ secrets.SSH_HOST_JUMP }} + proxy_username: ${{ secrets.SSH_USERNAME_JUMP }} + proxy_key: ${{ secrets.SSH_KEY_JUMP }} + proxy_port: ${{ secrets.SSH_PORT_JUMP }} + script: | + cd /home/admin/core/infra + git fetch + git checkout dev + git reset --hard origin/dev + git pull + infisical export --env=dev --format=dotenv --path="/api" --projectId=${{ secrets.INFISICAL_PROJECT_ID }} > ./secrets/.env.dev.api + docker compose -f docker-compose.dev.yml pull dev-email-worker + docker compose -f docker-compose.dev.yml up -d --no-deps --force-recreate dev-email-worker diff --git a/apps/api/.env.dev.example b/apps/api/.env.dev.example index c5c34a22..71121dd2 100644 --- a/apps/api/.env.dev.example +++ b/apps/api/.env.dev.example @@ -1,5 +1,6 @@ DATABASE_URL="postgres://postgres:postgres@postgres:5432/coredb" DATABASE_URL_MIGRATION="postgres://postgres:postgres@localhost:5432/coredb" +REDIS_URL="redis:6379" # For OAuth AUTH_DISCORD_CLIENT_ID= diff --git a/apps/api/cmd/api/main.go b/apps/api/cmd/api/main.go index 0d122dfc..52c0c1d5 100644 --- a/apps/api/cmd/api/main.go +++ b/apps/api/cmd/api/main.go @@ -4,6 +4,7 @@ import ( "net/http" "time" + "github.com/hibiken/asynq" "github.com/rs/zerolog/log" "github.com/swamphacks/core/apps/api/internal/api" "github.com/swamphacks/core/apps/api/internal/api/handlers" @@ -38,6 +39,11 @@ func main() { }, } + // Create asynq client + taskQueueClient := asynq.NewClient(asynq.RedisClientOpt{ + Addr: cfg.RedisURL, + }) + // Create new middleware injectable mw := middleware.NewMiddleware(database, logger, cfg) @@ -50,9 +56,10 @@ func main() { // Injections into services authService := services.NewAuthService(userRepo, accountRepo, sessionRepo, txm, client, logger, &cfg.Auth) eventInterestService := services.NewEventInterestService(eventInterestRepo, logger) + emailService := services.NewEmailService(taskQueueClient, logger) // Injections into handlers - apiHandlers := handlers.NewHandlers(authService, eventInterestService, cfg, logger) + apiHandlers := handlers.NewHandlers(authService, eventInterestService, emailService, cfg, logger) api := api.NewAPI(&logger, apiHandlers, mw) diff --git a/apps/api/cmd/email_worker/Dockerfile b/apps/api/cmd/email_worker/Dockerfile new file mode 100644 index 00000000..ddb8c8e7 --- /dev/null +++ b/apps/api/cmd/email_worker/Dockerfile @@ -0,0 +1,27 @@ +FROM golang:1.24-alpine AS base + +WORKDIR /app + +COPY ../../go.mod ../../go.sum ./ + +RUN go mod download + +COPY ../../ ./ + +# Dev +FROM base AS dev + +RUN go install github.com/air-verse/air@latest + +CMD ["air"] + +# Production +FROM base as prod + +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o email_worker ./ + +RUN apk --no-cache add ca-certificates + +CMD ["./email_worker"] + + diff --git a/apps/api/cmd/email_worker/main.go b/apps/api/cmd/email_worker/main.go new file mode 100644 index 00000000..22d65f0f --- /dev/null +++ b/apps/api/cmd/email_worker/main.go @@ -0,0 +1,40 @@ +package main + +import ( + "fmt" + "log" + + "github.com/hibiken/asynq" + "github.com/swamphacks/core/apps/api/internal/config" + "github.com/swamphacks/core/apps/api/internal/logger" + "github.com/swamphacks/core/apps/api/internal/services" + "github.com/swamphacks/core/apps/api/internal/tasks" + "github.com/swamphacks/core/apps/api/internal/workers" +) + +func main() { + logger := logger.New() + cfg := config.Load() + + srv := asynq.NewServer( + asynq.RedisClientOpt{Addr: cfg.RedisURL}, + asynq.Config{ + Concurrency: 10, + Queues: map[string]int{ + "email": 10, + }, + }, + ) + + emailService := services.NewEmailService(nil, logger) + emailWorker := workers.NewEmailWorker(emailService, logger) + + mux := asynq.NewServeMux() + + mux.HandleFunc(tasks.TypeSendEmail, emailWorker.HandleSendEmailTask) + fmt.Println("Starting email worker") + + if err := srv.Run(mux); err != nil { + log.Fatalf("Failed to run email worker") + } +} diff --git a/apps/api/go.mod b/apps/api/go.mod index 73abb4b0..49a249e8 100644 --- a/apps/api/go.mod +++ b/apps/api/go.mod @@ -7,21 +7,28 @@ require ( github.com/go-chi/chi/v5 v5.2.2 github.com/go-chi/cors v1.2.1 github.com/google/uuid v1.6.0 + github.com/hibiken/asynq v0.25.1 github.com/jackc/pgx/v5 v5.7.4 github.com/joho/godotenv v1.5.1 - github.com/lib/pq v1.10.9 github.com/rs/zerolog v1.34.0 ) require ( + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.19 // indirect + github.com/redis/go-redis/v9 v9.7.0 // indirect + github.com/robfig/cron/v3 v3.0.1 // indirect + github.com/spf13/cast v1.7.0 // indirect github.com/stretchr/testify v1.10.0 // indirect golang.org/x/crypto v0.38.0 // indirect golang.org/x/sync v0.14.0 // indirect - golang.org/x/sys v0.33.0 // indirect + golang.org/x/sys v0.34.0 // indirect golang.org/x/text v0.25.0 // indirect + golang.org/x/time v0.8.0 // indirect + google.golang.org/protobuf v1.35.2 // indirect ) diff --git a/apps/api/go.sum b/apps/api/go.sum index 3e9e272b..feb8ee50 100644 --- a/apps/api/go.sum +++ b/apps/api/go.sum @@ -1,16 +1,30 @@ +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA= github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618= github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hibiken/asynq v0.25.1 h1:phj028N0nm15n8O2ims+IvJ2gz4k2auvermngh9JhTw= +github.com/hibiken/asynq v0.25.1/go.mod h1:pazWNOLBu0FEynQRBvHA26qdIKRSmfdIfUm4HdsLmXg= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -21,8 +35,10 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= -github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= @@ -31,14 +47,24 @@ github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E= +github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= +github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= @@ -46,10 +72,14 @@ golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= +golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= +golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= +golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io= +google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/apps/api/internal/api/api.go b/apps/api/internal/api/api.go index b846840f..5407a2e8 100644 --- a/apps/api/internal/api/api.go +++ b/apps/api/internal/api/api.go @@ -71,6 +71,11 @@ func (api *API) setupRoutes(mw *mw.Middleware) { r.Post("/{eventId}/interest", api.Handlers.EventInterest.AddEmailToEvent) }) + // Email routes + api.Router.Route("/email", func(r chi.Router) { + r.Post("/queue", api.Handlers.Email.QueueEmail) + }) + // Protected test routes api.Router.Route("/protected", func(r chi.Router) { r.Use(mw.Auth.RequireAuth) diff --git a/apps/api/internal/api/handlers/email.go b/apps/api/internal/api/handlers/email.go new file mode 100644 index 00000000..ec24e488 --- /dev/null +++ b/apps/api/internal/api/handlers/email.go @@ -0,0 +1,58 @@ +package handlers + +import ( + "encoding/json" + "net/http" + + "github.com/rs/zerolog" + res "github.com/swamphacks/core/apps/api/internal/api/response" + "github.com/swamphacks/core/apps/api/internal/email" + "github.com/swamphacks/core/apps/api/internal/services" +) + +type EmailHandler struct { + emailService *services.EmailService + logger zerolog.Logger +} + +func NewEmailHandler(emailService *services.EmailService, logger zerolog.Logger) *EmailHandler { + return &EmailHandler{ + emailService: emailService, + logger: logger.With().Str("handler", "EmailHandler").Str("component", "email").Logger(), + } +} + +type QueueEmailRequest struct { + To string `json:"to"` + From string `json:"from"` + Body string `json:"body"` +} + +func (h *EmailHandler) QueueEmail(w http.ResponseWriter, r *http.Request) { + var req QueueEmailRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + res.SendError(w, http.StatusBadRequest, res.NewError("invalid_request", "Could not parse request body")) + return + } + + if !email.IsValidEmail(req.To) || !email.IsValidEmail(req.From) { + res.SendError(w, http.StatusBadRequest, res.NewError("malformed_email", "To and/or From email is malformed or missing")) + return + } + + if req.Body == "" { + res.SendError(w, http.StatusBadRequest, res.NewError("missing_body", "Body is missing or is an empty string.")) + return + } + + taskInfo, err := h.emailService.QueueSendEmail(req.To, req.From, req.Body) + if err != nil { + h.logger.Err(err).Msg("Failed to queue email sending from EmailHandler") + res.SendError(w, http.StatusInternalServerError, res.NewError("internal_err", "The server went kaput while queueing email sending")) + return + } + + h.logger.Info().Str("TaskID", taskInfo.ID).Str("Task Queue", taskInfo.Queue).Str("Task Type", taskInfo.Type).Msg("Queued Send Email task!") + + w.WriteHeader(http.StatusCreated) +} diff --git a/apps/api/internal/api/handlers/handlers.go b/apps/api/internal/api/handlers/handlers.go index 52d0d1ef..7841c371 100644 --- a/apps/api/internal/api/handlers/handlers.go +++ b/apps/api/internal/api/handlers/handlers.go @@ -9,11 +9,13 @@ import ( type Handlers struct { Auth *AuthHandler EventInterest *EventInterestHandler + Email *EmailHandler } -func NewHandlers(authService *services.AuthService, eventInterestService *services.EventInterestService, cfg *config.Config, logger zerolog.Logger) *Handlers { +func NewHandlers(authService *services.AuthService, eventInterestService *services.EventInterestService, emailService *services.EmailService, cfg *config.Config, logger zerolog.Logger) *Handlers { return &Handlers{ Auth: NewAuthHandler(authService, cfg, logger), EventInterest: NewEventInterestHandler(eventInterestService, cfg, logger), + Email: NewEmailHandler(emailService, logger), } } diff --git a/apps/api/internal/config/config.go b/apps/api/internal/config/config.go index b9039189..09bbd1b1 100644 --- a/apps/api/internal/config/config.go +++ b/apps/api/internal/config/config.go @@ -26,6 +26,7 @@ type AuthConfig struct { type Config struct { DatabaseURL string `env:"DATABASE_URL"` + RedisURL string `env:"REDIS_URL"` Port string `env:"PORT" envDefault:"8080"` Auth AuthConfig `envPrefix:"AUTH_"` diff --git a/apps/api/internal/services/email.go b/apps/api/internal/services/email.go new file mode 100644 index 00000000..829ee5a7 --- /dev/null +++ b/apps/api/internal/services/email.go @@ -0,0 +1,45 @@ +package services + +import ( + "github.com/hibiken/asynq" + "github.com/rs/zerolog" + "github.com/swamphacks/core/apps/api/internal/tasks" +) + +type EmailService struct { + logger zerolog.Logger + taskQueue *asynq.Client + // Add SMTP client later +} + +func NewEmailService(taskQueue *asynq.Client, logger zerolog.Logger) *EmailService { + return &EmailService{ + logger: logger.With().Str("service", "EmailService").Str("component", "email").Logger(), + taskQueue: taskQueue, + } +} + +func (s *EmailService) SendEmail(to, from, body string) error { + s.logger.Info().Msgf("Sending from %s to %s with body %s", from, to, body) + return nil +} + +func (s *EmailService) QueueSendEmail(to, from, body string) (*asynq.TaskInfo, error) { + task, err := tasks.NewTaskSendEmail(tasks.SendEmailPayload{ + To: to, + From: from, + Body: body, + }) + if err != nil { + s.logger.Err(err).Msg("Failed to create Send Email task") + return nil, err + } + + info, err := s.taskQueue.Enqueue(task, asynq.Queue("email")) + if err != nil { + s.logger.Err(err).Msg("Failed to queue Send Email task") + return nil, err + } + + return info, nil +} diff --git a/apps/api/internal/tasks/email.go b/apps/api/internal/tasks/email.go new file mode 100644 index 00000000..1a629810 --- /dev/null +++ b/apps/api/internal/tasks/email.go @@ -0,0 +1,26 @@ +package tasks + +import ( + "encoding/json" + + "github.com/hibiken/asynq" +) + +const ( + TypeSendEmail = "email:send" +) + +type SendEmailPayload struct { + To string + From string + Body string +} + +func NewTaskSendEmail(payload SendEmailPayload) (*asynq.Task, error) { + data, err := json.Marshal(payload) + if err != nil { + return nil, err + } + + return asynq.NewTask(TypeSendEmail, data), nil +} diff --git a/apps/api/internal/workers/email.go b/apps/api/internal/workers/email.go new file mode 100644 index 00000000..aa5324cf --- /dev/null +++ b/apps/api/internal/workers/email.go @@ -0,0 +1,37 @@ +package workers + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/hibiken/asynq" + "github.com/rs/zerolog" + "github.com/swamphacks/core/apps/api/internal/services" + "github.com/swamphacks/core/apps/api/internal/tasks" +) + +type EmailWorker struct { + emailService *services.EmailService + logger zerolog.Logger +} + +func NewEmailWorker(emailService *services.EmailService, logger zerolog.Logger) *EmailWorker { + return &EmailWorker{ + emailService: emailService, + logger: logger.With().Str("worker", "EmailWorker").Str("component", "email").Logger(), + } +} + +func (w *EmailWorker) HandleSendEmailTask(ctx context.Context, t *asynq.Task) error { + var p tasks.SendEmailPayload + if err := json.Unmarshal(t.Payload(), &p); err != nil { + w.logger.Err(err) + return fmt.Errorf("json.Unmarshal failed: %v: %w", err, asynq.SkipRetry) + } + + if err := w.emailService.SendEmail(p.To, p.From, p.Body); err != nil { + w.logger.Err(err).Msg("Failed to send email from worker") + } + return nil +} diff --git a/docker-compose.yml b/docker-compose.yml index d7bb5bc8..d59fa8ed 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,19 @@ services: depends_on: - postgres - redis + + email_worker: + build: + dockerfile: cmd/email_worker/Dockerfile + context: ./apps/api + target: dev + env_file: "./apps/api/.env.dev" + volumes: + - ./apps/api:/app + working_dir: /app/cmd/email_worker + depends_on: + - redis + web: build: context: ./apps/web @@ -25,6 +38,17 @@ services: depends_on: - api + asynqmon: + image: hibiken/asynqmon:latest + platform: linux/amd64 + ports: + - "6767:6767" + environment: + - REDIS_ADDR=redis:6379 + - PORT=6767 + depends_on: + - redis + postgres: image: postgres:17.4 container_name: postgres diff --git a/infra/docker-compose.dev.yml b/infra/docker-compose.dev.yml index c55fcec8..29550589 100644 --- a/infra/docker-compose.dev.yml +++ b/infra/docker-compose.dev.yml @@ -6,3 +6,9 @@ services: restart: always env_file: - ./secrets/.env.dev.api + + dev-email-worker: + image: ghcr.io/swamphacks/core-email-worker:dev + restart: always + env_file: + - ./secrets/.env.dev.api