Repository: gofiber/recipes
Pull request #3048: feat: Add AWS SES Email Sender Recipe
Author: login: lee-lou2
Created: 2025-05-07T04:51:16Z | Merged: 2025-05-12T06:34:31Z

Hi Team,

This PR introduces the **AWS SES Email Sender** recipe, a Golang-based email delivery service designed for robust email dispatch using AWS Simple Email Service (SES).

This recipe provides a comprehensive solution for sending emails, managing sending quotas, scheduling messages, and tracking delivery or engagement results. It is based on the functionalities of the [lee-lou2/aws-ses-sender-go](https://github.com/lee-lou2/aws-ses-sender-go) project.

**Key Features included in this recipe:**

* **AWS SES Integration:** Core functionality for sending emails via AWS SES.
* **Rate & Schedule Management:** Designed with daily limits, per-second sending rates, scheduled sending, and message grouping capabilities.
* **Tracking & Analytics:** Supports email open tracking and collection of delivery results for analysis.
* **API Endpoints:** Offers API endpoints for:
    * Email sending requests.
    * Viewing sending statistics by topic or time.
    * Tracking email open events.
    * Receiving delivery result callbacks from AWS SNS.
* **Technology Stack:** Built with Go (1.22+), AWS SES, and PostgreSQL (14.0+).

The `README.md` within the recipe's directory contains detailed information on:
* Essential requirements (AWS setup, PostgreSQL).
* Project structure.
* Setup instructions (including `.env` configuration).
* API endpoint specifications and usage examples.

This recipe aims to provide a practical example of building an email sending service with Go and AWS SES.

Thank you for your review and feedback! 🙏

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **New Features**
  - Introduced a new AWS SES Email Sender service for sending, scheduling, and tracking emails with analytics.
  - Added API endpoints for sending emails, tracking opens, receiving delivery results, and viewing statistics.
  - Implemented rate-limited, scheduled email dispatch and delivery status tracking.
  - Provided open tracking and result analysis capabilities.
  - Included Docker support and comprehensive documentation.

- **Documentation**
  - Added detailed README with setup instructions, API usage, and system architecture diagram.

- **Chores**
  - Added configuration files and environment variable management for easier setup and deployment.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->


Diff:
diff --git a/README.md b/README.md
index 8d018b68fa..4e1454bf01 100644
--- a/README.md
+++ b/README.md
@@ -21,6 +21,7 @@ Here you can find the most **delicious** recipes to cook delicious meals using o
 - [AWS Elastic Beanstalk](./aws-eb/README.md) - Deploying to AWS Elastic Beanstalk.
 - [AWS SAM](./aws-sam/README.md) - Serverless applications with AWS SAM.
 - [AWS SAM Container](./aws-sam-container/README.md) - Containerized serverless applications with AWS SAM.
+- [AWS SES Email Sender](./aws-ses-sender/README.md) - AWS SES-based Golang email delivery service. Provides email dispatch processing, status tracking, scheduled sending, and result analysis capabilities.
 - [Bootstrap](./bootstrap/README.md) - Integrating Bootstrap.
 - [Clean Architecture](./clean-architecture/README.md) - Implementing clean architecture in Go.
 - [Clean Code](./clean-code/README.md) - Implementing clean code in Go.
diff --git a/aws-ses-sender/.gitignore b/aws-ses-sender/.gitignore
new file mode 100644
index 0000000000..de03f38038
--- /dev/null
+++ b/aws-ses-sender/.gitignore
@@ -0,0 +1,2 @@
+.idea
+.env
diff --git a/aws-ses-sender/Dockerfile b/aws-ses-sender/Dockerfile
new file mode 100644
index 0000000000..2d21c43435
--- /dev/null
+++ b/aws-ses-sender/Dockerfile
@@ -0,0 +1,21 @@
+FROM golang:1.24 AS builder
+
+WORKDIR /usr/src/app
+
+COPY go.mod go.sum ./
+RUN go mod download && go mod verify
+
+COPY . .
+
+RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app .
+
+FROM alpine:3.21
+
+RUN apk --no-cache add ca-certificates
+
+WORKDIR /root/
+
+COPY --from=builder /usr/src/app/.env .env
+COPY --from=builder /usr/src/app/app .
+
+CMD ["./app"]
diff --git a/aws-ses-sender/README.md b/aws-ses-sender/README.md
new file mode 100644
index 0000000000..38ab7ea175
--- /dev/null
+++ b/aws-ses-sender/README.md
@@ -0,0 +1,179 @@
+---
+title: AWS SES Email Sender
+keywords: [aws, ses, golang, email, sender]
+description: AWS SES-based Golang email delivery service. Provides email dispatch processing, status tracking, scheduled sending, and result analysis capabilities.
+---
+
+# AWS SES Email Sender
+
+[![Github](https://img.shields.io/static/v1?label=&message=Github&color=2ea44f&style=for-the-badge&logo=github)](https://github.com/gofiber/recipes/tree/master/aws-ses-sender) [![StackBlitz](https://img.shields.io/static/v1?label=&message=StackBlitz&color=2ea44f&style=for-the-badge&logo=StackBlitz)](https://stackblitz.com/github/gofiber/recipes/tree/master/aws-ses-sender)
+
+This is an AWS SES-based Golang email delivery service that extracts only the basic sending functionality from [my open-source project](https://github.com/lee-lou2/aws-ses-sender-go).
+
+## Features
+- [x] Email sending using AWS SES
+- [x] Designed with daily limits and per-second sending rates in mind
+- [x] Scheduled sending and message grouping
+- [x] Email open tracking and result collection
+- [x] View daily sending counts and delivery results by message group
+
+## Flowchart
+
+```mermaid
+flowchart TD
+    A[Client] -->|Email Send Request| B[API Server]
+    B -->|DB Storage| C[Scheduler]
+    C -->|Query Pending| D[Sender]
+    D -->|SES Send| E[AWS SES]
+    E -->|Email Received| F[Recipient]
+    E -->|SNS Callback| G[API Server]
+    G -->|DB Storage| H[Results/Statistics]
+    F -->|Open Tracking| I[API Server]
+    I -->|DB Storage| J[Open Events]
+    H -->|Stats/Results Query| K[Client]
+    J -->|Stats/Results Query| K[Client]
+```
+
+## Requirements
+
+### Essential Requirements
+- Go 1.22 or higher
+- AWS account and configuration
+  - AWS SES service activated
+  - Sender email or domain verification completed
+  - IAM user with SES permissions
+  - AWS Access Key and Secret Key
+- PostgreSQL 14.0 or higher
+- (Optional) Docker
+
+### AWS SES Configuration
+1. Verify sender email/domain in AWS SES console
+2. Request removal from SES sandbox mode (for production)
+3. Create SNS topic and set up SES feedback notifications
+4. Grant following permissions to IAM user
+  - `ses:SendEmail`
+  - `ses:SendRawEmail`
+  - `sns:Publish` (if using SNS for delivery notifications)
+  - `sns:Subscribe` (if using SNS for delivery notifications)
+
+## Project Structure
+```plaintext
+aws-ses-sender/
+├── main.go              # Application entry point
+├── api/                 # HTTP API related code
+│   ├── handler.go       # API handler functions
+│   ├── route.go         # API routing configuration
+│   ├── server.go        # HTTP server setup/execution
+│   └── middlewares.go   # API authentication middleware
+├── cmd/                 # Background job code
+│   ├── scheduler.go     # Pending email scheduler
+│   └── sender.go        # SES email sending processor
+├── config/              # Application settings
+│   ├── env.go           # Environment variable management
+│   └── db.go            # Database connection settings
+├── model/               # Database models
+│   └── email.go         # GORM model definitions
+└── pkg/aws/             # AWS service integration
+    └── ses.go           # SES email sending
+```
+
+## Setup
+
+### Prerequisites
+
+- Go language development environment
+- AWS account and SES service configuration
+  - Sender email/domain verification
+  - IAM user creation with SES permissions
+- PostgreSQL database
+- (Optional) Sentry DSN
+
+### Configuration
+
+Create a `.env` file in the project root and set the following environment variables:
+
+```env
+# AWS Related
+AWS_ACCESS_KEY_ID=your_access_key
+AWS_SECRET_ACCESS_KEY=your_secret_key
+AWS_REGION=ap-northeast-2
+EMAIL_SENDER=sender@example.com
+
+# Server and API
+SERVER_PORT=3000
+API_KEY=your_api_key
+SERVER_HOST=http://localhost:3000
+
+# Database (PostgreSQL)
+DB_HOST=localhost
+DB_PORT=5432
+DB_USER=postgres
+DB_PASSWORD=postgres
+DB_NAME=postgres
+
+# Sending rate per second
+EMAIL_RATE=14
+
+# Sentry (Optional)
+SENTRY_DSN=your_sentry_dsn
+```
+
+### Installation and Execution
+
+1. Clone repository:
+   ```bash
+   git clone <repository_URL>
+   cd aws-ses-sender
+   ```
+
+2. Install dependencies:
+   ```bash
+   go mod tidy
+   ```
+
+3. Run application:
+   ```bash
+   go run main.go
+   ```
+
+## API Endpoints
+
+### Email Sending Request
+```http
+POST /v1/messages
+```
+
+Request body example:
+```json
+{
+  "messages": [
+    {
+      "topicId": "promotion-event-2024",
+      "emails": ["recipient1@example.com", "recipient2@example.com"],
+      "subject": "Special Promotion Notice",
+      "content": "<h1>Hello!</h1><p>Check out our special promotion details.</p>",
+      "scheduledAt": "2024-12-25T10:00:00+09:00"
+    }
+  ]
+}
+```
+
+### View Topic Sending Statistics
+```http
+GET /v1/topics/:topicId
+```
+
+### Email Open Tracking
+```http
+GET /v1/events/open?requestId={requestId}
+```
+
+### View Sending Statistics
+```http
+GET /v1/events/counts/sent?hours={hours}
+```
+
+### Receive Sending Results (AWS SNS)
+```http
+POST /v1/events/results
+```
\ No newline at end of file
diff --git a/aws-ses-sender/api/handler.go b/aws-ses-sender/api/handler.go
new file mode 100644
index 0000000000..28d73a418e
--- /dev/null
+++ b/aws-ses-sender/api/handler.go
@@ -0,0 +1,319 @@
+package api
+
+import (
+	"aws-ses-sender/config"
+	"aws-ses-sender/model"
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"image"
+	"image/color"
+	"image/png"
+	"log"
+	"net/mail"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/gofiber/fiber/v3"
+)
+
+// createMessageHandler Message Handler
+// Handler that receives email sending requests
+func createMessageHandler(c fiber.Ctx) error {
+	start := time.Now()
+	var reqBody struct {
+		Messages []struct {
+			TopicId     string   `json:"topicId"`
+			Emails      []string `json:"emails"`
+			Subject     string   `json:"subject"`
+			Content     string   `json:"content"`
+			ScheduledAt string   `json:"scheduledAt"`
+		} `json:"messages"`
+	}
+	if err := c.Bind().JSON(&reqBody); err != nil {
+		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
+	}
+
+	db := config.GetDB()
+	reqs := make([]*model.Request, 0)
+	totCnt := 0
+	for _, msg := range reqBody.Messages {
+		scheduledAt := time.Now().UTC()
+
+		if msg.ScheduledAt != "" {
+			if t, err := time.Parse(time.RFC3339, msg.ScheduledAt); err != nil {
+				return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid scheduledAt format"})
+			} else {
+				scheduledAt = t.UTC()
+				// Ensure scheduled time is in the future
+				if scheduledAt.Before(time.Now().UTC()) {
+					log.Printf("Scheduled time must be in the future")
+					continue
+				}
+			}
+		}
+		for _, email := range msg.Emails {
+			// Validate email address
+			if _, err := mail.ParseAddress(email); err != nil {
+				return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": fmt.Sprintf("invalid email: %s", email)})
+			}
+			// Validate essential message fields
+			if strings.TrimSpace(msg.Subject) == "" {
+				return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Subject cannot be empty"})
+			}
+			if strings.TrimSpace(msg.Content) == "" {
+				return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Content cannot be empty"})
+			}
+			req := &model.Request{
+				TopicId:     msg.TopicId,
+				To:          email,
+				Subject:     msg.Subject,
+				Content:     msg.Content,
+				ScheduledAt: &scheduledAt,
+				Status:      model.EmailMessageStatusCreated,
+			}
+			reqs = append(reqs, req)
+			totCnt++
+		}
+	}
+
+	chunkSize := 1000
+	for i := 0; i < len(reqs); i += chunkSize {
+		end := i + chunkSize
+		if end > len(reqs) {
+			end = len(reqs)
+		}
+		if err := db.Create(reqs[i:end]).Error; err != nil {
+			return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+		}
+	}
+
+	// Return the result
+	return c.JSON(fiber.Map{
+		"count":   totCnt,
+		"elapsed": time.Since(start).String(),
+	})
+}
+
+// createOpenEventHandler Open Event Handler
+// Attach an image script to the email and assume it has been read when the image is accessed
+func createOpenEventHandler(c fiber.Ctx) error {
+	reqId := c.Query("requestId")
+	if reqId != "" {
+		// Consider email as opened and create data
+		db := config.GetDB()
+		if reqIdInt, err := strconv.Atoi(reqId); err == nil {
+			if err := db.Create(&model.Result{
+				RequestId: uint(reqIdInt),
+				Status:    "Open",
+				Raw:       "{}",
+			}).Error; err != nil {
+				log.Printf("Failed to create open event: %s", err.Error())
+			}
+		} else {
+			log.Printf("Invalid requestId: %s", reqId)
+		}
+	}
+
+	// Return a blank image
+	img := image.NewRGBA(image.Rect(0, 0, 1, 1))
+	img.Set(0, 0, color.RGBA{R: 0, G: 0, B: 0, A: 0})
+	var buf bytes.Buffer
+	if err := png.Encode(&buf, img); err != nil {
+		return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+	}
+	c.Set("Content-Type", "image/png")
+	return c.Send(buf.Bytes())
+}
+
+// createResultEventHandler Result Event Handler
+// Handler that receives AWS SES results
+func createResultEventHandler(c fiber.Ctx) error {
+	msgType := c.Get("x-amz-sns-message-type")
+	if msgType != "Notification" && msgType != "SubscriptionConfirmation" {
+		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid SNS Message Type"})
+	}
+
+	var reqBody struct {
+		Type         string `json:"Type"`
+		Message      string `json:"Message"`
+		MessageId    string `json:"MessageId"`
+		SubscribeURL string `json:"SubscribeURL"`
+	}
+	if err := c.Bind().JSON(&reqBody); err != nil {
+		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Failed to parse SNS message"})
+	}
+
+	if reqBody.Type == "SubscriptionConfirmation" {
+		// Subscription confirmation
+		log.Printf("Subscription confirmation required. Visiting: %s", reqBody.SubscribeURL)
+		return c.JSON(fiber.Map{"message": "Subscription confirmation required"})
+	}
+
+	if reqBody.Type != "Notification" {
+		return c.JSON(fiber.Map{"message": "Other message type received"})
+	}
+
+	var sesNotification struct {
+		NotificationType string `json:"notificationType"`
+		Mail             struct {
+			MessageId string `json:"messageId"`
+			Headers   []struct {
+				Name  string `json:"name"`
+				Value string `json:"value"`
+			} `json:"headers"`
+		} `json:"mail"`
+	}
+	if err := json.Unmarshal([]byte(reqBody.Message), &sesNotification); err != nil {
+		return c.JSON(fiber.Map{"message": fmt.Sprintf("Non-SES notification received: %v", err)})
+	}
+	if sesNotification.Mail.MessageId == "" {
+		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "SES message_id not found"})
+	}
+
+	// Log notification type for monitoring
+	log.Printf("Received %s notification from SES for message %s", sesNotification.NotificationType, sesNotification.Mail.MessageId)
+
+	// Find request_id from headers
+	var reqId uint
+	for _, header := range sesNotification.Mail.Headers {
+		if strings.EqualFold(header.Name, "X-Request-ID") {
+			if reqIdInt, err := strconv.Atoi(header.Value); err == nil {
+				reqId = uint(reqIdInt)
+			} else {
+				log.Printf("Invalid requestId: %s", header.Value)
+			}
+			break
+		}
+	}
+
+	if reqId == 0 {
+		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Request ID not found in headers"})
+	}
+
+	db := config.GetDB()
+	if err := db.Create(&model.Result{
+		RequestId: reqId,
+		Status:    sesNotification.NotificationType,
+		Raw:       reqBody.Message,
+	}).Error; err != nil {
+		log.Printf("Failed to save result event: %v", err)
+		return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to save event"})
+	}
+	return c.JSON(fiber.Map{"message": "OK"})
+}
+
+// getResultCountHandler Retrieve email delivery results as counts
+func getResultCountHandler(c fiber.Ctx) error {
+	topicID := c.Params("topicId")
+	if topicID == "" {
+		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "topicId is required"})
+	}
+
+	db := config.GetDB()
+
+	// Check if any requests exist for the given topicID.  Early exit if none.
+	var reqCnt int64
+	if err := db.Model(&model.Request{}).Where("topic_id = ?", topicID).Count(&reqCnt).Error; err != nil {
+		return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+	}
+	if reqCnt == 0 {
+		return c.JSON(fiber.Map{
+			"request": fiber.Map{"total": 0, "created": 0, "sent": 0, "failed": 0, "stopped": 0},
+			"result":  fiber.Map{"total": 0, "statuses": map[string]int{}},
+		})
+	}
+
+	// --- Request Counts (Efficient Single Query) ---
+	var reqResults []struct {
+		Status int
+		Count  int
+	}
+	if err := db.Model(&model.Request{}).
+		Select("status, COUNT(*) as count").
+		Where("topic_id = ?", topicID).
+		Group("status").
+		Scan(&reqResults).Error; err != nil {
+		return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+	}
+
+	reqCnts := struct {
+		Total   int `json:"total"`
+		Created int `json:"created"`
+		Sent    int `json:"sent"`
+		Failed  int `json:"failed"`
+		Stopped int `json:"stopped"`
+	}{Total: int(reqCnt)} // Initialize Total with requestCount
+
+	for _, r := range reqResults {
+		switch r.Status {
+		case model.EmailMessageStatusCreated:
+			reqCnts.Created = r.Count
+		case model.EmailMessageStatusSent:
+			reqCnts.Sent = r.Count
+		case model.EmailMessageStatusFailed:
+			reqCnts.Failed = r.Count
+		case model.EmailMessageStatusStopped:
+			reqCnts.Stopped = r.Count
+		}
+	}
+
+	// --- Result Counts (Optimized with Subquery) ---
+
+	// Use a subquery to get the distinct request IDs associated with the topicID.
+	// This is generally the most efficient approach with GORM and avoids extra Go-side processing.
+	subQuery := db.Model(&model.Request{}).Select("id").Where("topic_id = ?", topicID)
+
+	var resultResults []struct {
+		Status string
+		Count  int
+	}
+	if err := db.Model(&model.Result{}).
+		Select("status, COUNT(DISTINCT request_id) as count").
+		Where("request_id IN (?)", subQuery).
+		Group("status").
+		Scan(&resultResults).Error; err != nil {
+		return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+	}
+
+	// Use a map for flexible status handling.
+	resultCounts := make(map[string]int)
+	for _, r := range resultResults {
+		resultCounts[r.Status] = r.Count
+	}
+
+	// --- Return Combined Result ---
+	return c.JSON(fiber.Map{
+		"request": reqCnts,
+		"result": fiber.Map{
+			"statuses": resultCounts,
+		},
+	})
+}
+
+// getSentCountHandler Retrieve the number of emails sent within 24 hours
+func getSentCountHandler(c fiber.Ctx) error {
+	// Receive hours as a query string (default 24)
+	hours, err := strconv.Atoi(c.Query("hours", "24"))
+	if err != nil || hours <= 0 || hours > 168 {
+		log.Printf("Invalid hours: %v", err)
+		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "hours must be 1-168"})
+	}
+
+	// Calculate the time hours before the current time
+	startTime := time.Now().Add(-time.Duration(hours) * time.Hour)
+
+	// Get the number of emails after startTime from the DB
+	db := config.GetDB()
+	var cnt int64
+	if err := db.Model(&model.Request{}).
+		Where("updated_at > ?", startTime).
+		Where("status = ?", model.EmailMessageStatusSent).
+		Count(&cnt).Error; err != nil {
+		return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+	}
+
+	// Return the result
+	return c.JSON(fiber.Map{"count": cnt})
+}
diff --git a/aws-ses-sender/api/middlewares.go b/aws-ses-sender/api/middlewares.go
new file mode 100644
index 0000000000..f793977f88
--- /dev/null
+++ b/aws-ses-sender/api/middlewares.go
@@ -0,0 +1,21 @@
+package api
+
+import (
+	"aws-ses-sender/config"
+	"crypto/subtle"
+
+	"github.com/gofiber/fiber/v3"
+)
+
+// apiKeyAuth API Key Authentication Middleware
+// Middleware to check for the API key in the request header
+func apiKeyAuth(c fiber.Ctx) error {
+	apiKey := c.Get("x-api-key")
+	expectedAPIKey := config.GetEnv("API_KEY", "")
+	if expectedAPIKey == "" || subtle.ConstantTimeCompare([]byte(apiKey), []byte(expectedAPIKey)) != 1 {
+		return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
+			"error": "Unauthorized: Invalid API key",
+		})
+	}
+	return c.Next()
+}
diff --git a/aws-ses-sender/api/route.go b/aws-ses-sender/api/route.go
new file mode 100644
index 0000000000..8bf6272654
--- /dev/null
+++ b/aws-ses-sender/api/route.go
@@ -0,0 +1,20 @@
+package api
+
+import (
+	"github.com/gofiber/fiber/v3"
+)
+
+// setV1Routes V1 Routes
+func setV1Routes(app *fiber.App) {
+	v1 := app.Group("/v1")
+	{
+		// Messages
+		v1.Post("/messages", createMessageHandler, apiKeyAuth)
+		// Topics
+		v1.Get("/topics/:topicId", getResultCountHandler, apiKeyAuth)
+		// Events
+		v1.Get("/events/open", createOpenEventHandler)
+		v1.Get("/events/counts/sent", getSentCountHandler, apiKeyAuth)
+		v1.Post("/events/results", createResultEventHandler)
+	}
+}
diff --git a/aws-ses-sender/api/server.go b/aws-ses-sender/api/server.go
new file mode 100644
index 0000000000..57059465f7
--- /dev/null
+++ b/aws-ses-sender/api/server.go
@@ -0,0 +1,45 @@
+package api
+
+import (
+	"aws-ses-sender/config"
+	"context"
+	"fmt"
+	"log"
+
+	"github.com/gofiber/fiber/v3/middleware/pprof"
+
+	"github.com/gofiber/fiber/v3"
+	"github.com/gofiber/fiber/v3/middleware/logger"
+	recoverer "github.com/gofiber/fiber/v3/middleware/recover"
+	jsoniter "github.com/json-iterator/go"
+)
+
+func Run(ctx context.Context) {
+	app := fiber.New(
+		fiber.Config{
+			AppName: "aws-ses-sender",
+			// JSON Encoder and Decoder(Fast JSON library)
+			JSONEncoder: jsoniter.Marshal,
+			JSONDecoder: jsoniter.Unmarshal,
+		},
+	)
+
+	// Middleware
+	app.Use(pprof.New())
+	app.Use(recoverer.New())
+	app.Use(logger.New(logger.Config{
+		Format:     "${time} ${pid} ${status} - ${method} ${path} ${latency}\n",
+		TimeFormat: "2006-01-02 15:04:05",
+		TimeZone:   "Local",
+	}))
+
+	// Routes
+	setV1Routes(app)
+
+	go func() {
+		log.Fatal(app.Listen(fmt.Sprintf(":%s", config.GetEnv("SERVER_PORT", "3000"))))
+	}()
+
+	<-ctx.Done()
+	app.Shutdown()
+}
diff --git a/aws-ses-sender/cmd/scheduler.go b/aws-ses-sender/cmd/scheduler.go
new file mode 100644
index 0000000000..f2c88031a6
--- /dev/null
+++ b/aws-ses-sender/cmd/scheduler.go
@@ -0,0 +1,67 @@
+package cmd
+
+import (
+	"aws-ses-sender/config"
+	"aws-ses-sender/model"
+	"context"
+	"log"
+	"strconv"
+	"time"
+)
+
+// RunScheduler runs the scheduler
+// It schedules the email sending requests to be processed by the sender
+func RunScheduler(ctx context.Context) {
+	db := config.GetDB()
+	sendPerSecStr := config.GetEnv("EMAIL_RATE", "14")
+	sendPerSec, err := strconv.Atoi(sendPerSecStr)
+	if err != nil {
+		log.Fatalf("Invalid EMAIL_RATE: %v", err)
+	}
+	sendPerMin := sendPerSec * 60
+	batchSize := 1000
+
+	ticker := time.NewTicker(1 * time.Minute)
+	for {
+		select {
+		case <-ctx.Done():
+			return
+		case <-ticker.C:
+			for i := 0; i < sendPerMin; i += batchSize {
+				now := time.Now().UTC()
+				reqs := make([]*model.Request, 0, batchSize)
+				err := db.Raw(`
+				WITH locked_requests AS (
+					SELECT id
+					FROM email_requests
+					WHERE status = ? AND (scheduled_at <= ? OR scheduled_at IS NULL) AND deleted_at IS NULL
+					ORDER BY id ASC
+					LIMIT ?
+					FOR UPDATE SKIP LOCKED
+				)
+				UPDATE email_requests
+				SET status = ?, updated_at = ?
+				FROM locked_requests
+				WHERE email_requests.id = locked_requests.id
+				RETURNING email_requests.*;
+			`,
+					model.EmailMessageStatusCreated,
+					now,
+					batchSize,
+					model.EmailMessageStatusProcessing,
+					now,
+				).Scan(&reqs).Error
+
+				if err != nil {
+					log.Printf("Update Returning Error: %v", err)
+				} else if len(reqs) > 0 {
+					for _, req := range reqs {
+						reqChan <- req
+					}
+				} else {
+					break
+				}
+			}
+		}
+	}
+}
diff --git a/aws-ses-sender/cmd/sender.go b/aws-ses-sender/cmd/sender.go
new file mode 100644
index 0000000000..aa0ec97edc
--- /dev/null
+++ b/aws-ses-sender/cmd/sender.go
@@ -0,0 +1,65 @@
+package cmd
+
+import (
+	"aws-ses-sender/config"
+	"aws-ses-sender/model"
+	"aws-ses-sender/pkg/aws"
+	"context"
+	"log"
+	"strconv"
+	"time"
+)
+
+var reqChan = make(chan *model.Request, 1000)
+
+// RunSender runs the email sender
+// It consumes the email sending requests from the channel and sends them to the AWS SES
+func RunSender(ctx context.Context) {
+	rateStr := config.GetEnv("EMAIL_RATE", "14")
+	rate, err := strconv.Atoi(rateStr)
+	if err != nil {
+		log.Fatalf("Invalid EMAIL_RATE: %v", err)
+	}
+	sesClient, err := aws.NewSESClient(ctx)
+	if err != nil {
+		log.Fatalf("Failed to create SES client: %v", err)
+	}
+
+	db := config.GetDB()
+	ticker := time.NewTicker(1 * time.Second / time.Duration(rate))
+	for range ticker.C {
+		select {
+		case <-ctx.Done():
+			return
+		case req := <-reqChan:
+			go func(c context.Context, r *model.Request) {
+				// Add code for the open event at the end of the body
+				serverHost := config.GetEnv("SERVER_HOST", "http://localhost:3000")
+				content := r.Content
+				content += `<img src="` + serverHost + `/v1/events/open?requestId=` + strconv.Itoa(int(r.ID)) + `">`
+				ctx, cancel := context.WithTimeout(c, 10*time.Second)
+				defer cancel()
+				msgId, err := sesClient.SendEmail(
+					ctx,
+					int(r.ID),
+					&r.Subject,
+					&content,
+					[]string{r.To},
+				)
+				status := model.EmailMessageStatusSent
+				errMsg := ""
+				if err != nil {
+					status = model.EmailMessageStatusFailed
+					errMsg = err.Error()
+				}
+				db.Model(&model.Request{}).
+					Where("id = ?", r.ID).
+					Updates(model.Request{
+						MessageId: msgId,
+						Status:    status,
+						Error:     errMsg,
+					})
+			}(ctx, req)
+		}
+	}
+}
diff --git a/aws-ses-sender/config/db.go b/aws-ses-sender/config/db.go
new file mode 100644
index 0000000000..9dbb401e0c
--- /dev/null
+++ b/aws-ses-sender/config/db.go
@@ -0,0 +1,54 @@
+package config
+
+import (
+	"fmt"
+	"log"
+	"sync"
+
+	"gorm.io/driver/postgres"
+	"gorm.io/gorm"
+	"gorm.io/gorm/logger"
+)
+
+var (
+	dbInstance *gorm.DB
+	dbOnce     sync.Once
+)
+
+// GetDB Database instance
+func GetDB() *gorm.DB {
+	dbOnce.Do(func() {
+		host := GetEnv("DB_HOST", "localhost")
+		port := GetEnv("DB_PORT", "5432")
+		user := GetEnv("DB_USER", "postgres")
+		password := GetEnv("DB_PASSWORD", "postgres")
+		dbname := GetEnv("DB_NAME", "postgres")
+		dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=disable TimeZone=UTC",
+			host, user, password, dbname, port)
+		db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
+			Logger: logger.Default.LogMode(logger.Error),
+		})
+		if err != nil {
+			log.Fatalf("Failed to connect to database: %v", err)
+		}
+		sqlDB, err := db.DB()
+		if err != nil {
+			log.Fatalf("Failed to get database instance: %v", err)
+		}
+		sqlDB.SetMaxOpenConns(10)
+		dbInstance = db
+	})
+	return dbInstance
+}
+
+// CloseDB closes the database connection
+func CloseDB() error {
+	if dbInstance == nil {
+		return nil
+	}
+	sqlDB, err := dbInstance.DB()
+	if err != nil {
+		return fmt.Errorf("failed to get database instance: %v", err)
+	}
+	return sqlDB.Close()
+}
diff --git a/aws-ses-sender/config/env.go b/aws-ses-sender/config/env.go
new file mode 100644
index 0000000000..e8960d0c8d
--- /dev/null
+++ b/aws-ses-sender/config/env.go
@@ -0,0 +1,27 @@
+package config
+
+import (
+	"log"
+	"os"
+	"sync"
+
+	"github.com/joho/godotenv"
+)
+
+var envOnce sync.Once
+
+// GetEnv retrieves environment variables
+func GetEnv(key string, defaults ...string) string {
+	envOnce.Do(func() {
+		if err := godotenv.Load(); err != nil {
+			log.Printf("Warning: .env file not found or error loading: %v", err)
+		}
+	})
+	if len(defaults) > 0 {
+		if value := os.Getenv(key); value != "" {
+			return value
+		}
+		return defaults[0]
+	}
+	return os.Getenv(key)
+}
diff --git a/aws-ses-sender/go.mod b/aws-ses-sender/go.mod
new file mode 100644
index 0000000000..26a8aad0dc
--- /dev/null
+++ b/aws-ses-sender/go.mod
@@ -0,0 +1,57 @@
+module aws-ses-sender
+
+go 1.23
+
+require (
+	github.com/aws/aws-sdk-go-v2 v1.36.1
+	github.com/aws/aws-sdk-go-v2/config v1.29.6
+	github.com/aws/aws-sdk-go-v2/credentials v1.17.59
+	github.com/aws/aws-sdk-go-v2/service/sesv2 v1.41.5
+	github.com/getsentry/sentry-go v0.31.1
+	github.com/gofiber/fiber/v3 v3.0.0-beta.4
+	github.com/joho/godotenv v1.5.1
+	github.com/json-iterator/go v1.1.12
+	gorm.io/driver/postgres v1.5.11
+	gorm.io/gorm v1.25.12
+)
+
+require (
+	github.com/andybalholm/brotli v1.1.1 // indirect
+	github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.28 // indirect
+	github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.32 // indirect
+	github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.32 // indirect
+	github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2 // indirect
+	github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.32 // indirect
+	github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.2 // indirect
+	github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.13 // indirect
+	github.com/aws/aws-sdk-go-v2/service/sso v1.24.15 // indirect
+	github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.14 // indirect
+	github.com/aws/aws-sdk-go-v2/service/sts v1.33.14 // indirect
+	github.com/aws/smithy-go v1.22.2 // indirect
+	github.com/fxamacker/cbor/v2 v2.7.0 // indirect
+	github.com/gofiber/schema v1.2.0 // indirect
+	github.com/gofiber/utils/v2 v2.0.0-beta.7 // indirect
+	github.com/google/uuid v1.6.0 // indirect
+	github.com/jackc/pgpassfile v1.0.0 // indirect
+	github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
+	github.com/jackc/pgx/v5 v5.5.5 // indirect
+	github.com/jackc/puddle/v2 v2.2.1 // indirect
+	github.com/jinzhu/inflection v1.0.0 // indirect
+	github.com/jinzhu/now v1.1.5 // indirect
+	github.com/klauspost/compress v1.17.11 // indirect
+	github.com/mattn/go-colorable v0.1.13 // indirect
+	github.com/mattn/go-isatty v0.0.20 // indirect
+	github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
+	github.com/modern-go/reflect2 v1.0.2 // indirect
+	github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect
+	github.com/tinylib/msgp v1.2.5 // indirect
+	github.com/valyala/bytebufferpool v1.0.0 // indirect
+	github.com/valyala/fasthttp v1.58.0 // indirect
+	github.com/valyala/tcplisten v1.0.0 // indirect
+	github.com/x448/float16 v0.8.4 // indirect
+	golang.org/x/crypto v0.33.0 // indirect
+	golang.org/x/net v0.34.0 // indirect
+	golang.org/x/sync v0.11.0 // indirect
+	golang.org/x/sys v0.30.0 // indirect
+	golang.org/x/text v0.22.0 // indirect
+)
diff --git a/aws-ses-sender/go.sum b/aws-ses-sender/go.sum
new file mode 100644
index 0000000000..3345963f46
--- /dev/null
+++ b/aws-ses-sender/go.sum
@@ -0,0 +1,126 @@
+github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
+github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
+github.com/aws/aws-sdk-go-v2 v1.36.1 h1:iTDl5U6oAhkNPba0e1t1hrwAo02ZMqbrGq4k5JBWM5E=
+github.com/aws/aws-sdk-go-v2 v1.36.1/go.mod h1:5PMILGVKiW32oDzjj6RU52yrNrDPUHcbZQYr1sM7qmM=
+github.com/aws/aws-sdk-go-v2/config v1.29.6 h1:fqgqEKK5HaZVWLQoLiC9Q+xDlSp+1LYidp6ybGE2OGg=
+github.com/aws/aws-sdk-go-v2/config v1.29.6/go.mod h1:Ft+WLODzDQmCTHDvqAH1JfC2xxbZ0MxpZAcJqmE1LTQ=
+github.com/aws/aws-sdk-go-v2/credentials v1.17.59 h1:9btwmrt//Q6JcSdgJOLI98sdr5p7tssS9yAsGe8aKP4=
+github.com/aws/aws-sdk-go-v2/credentials v1.17.59/go.mod h1:NM8fM6ovI3zak23UISdWidyZuI1ghNe2xjzUZAyT+08=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.28 h1:KwsodFKVQTlI5EyhRSugALzsV6mG/SGrdjlMXSZSdso=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.28/go.mod h1:EY3APf9MzygVhKuPXAc5H+MkGb8k/DOSQjWS0LgkKqI=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.32 h1:BjUcr3X3K0wZPGFg2bxOWW3VPN8rkE3/61zhP+IHviA=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.32/go.mod h1:80+OGC/bgzzFFTUmcuwD0lb4YutwQeKLFpmt6hoWapU=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.32 h1:m1GeXHVMJsRsUAqG6HjZWx9dj7F5TR+cF1bjyfYyBd4=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.32/go.mod h1:IitoQxGfaKdVLNg0hD8/DXmAqNy0H4K2H2Sf91ti8sI=
+github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2 h1:Pg9URiobXy85kgFev3og2CuOZ8JZUBENF+dcgWBaYNk=
+github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.32 h1:OIHj/nAhVzIXGzbAE+4XmZ8FPvro3THr6NlqErJc3wY=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.32/go.mod h1:LiBEsDo34OJXqdDlRGsilhlIiXR7DL+6Cx2f4p1EgzI=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.2 h1:D4oz8/CzT9bAEYtVhSBmFj2dNOtaHOtMKc2vHBwYizA=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.2/go.mod h1:Za3IHqTQ+yNcRHxu1OFucBh0ACZT4j4VQFF0BqpZcLY=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.13 h1:SYVGSFQHlchIcy6e7x12bsrxClCXSP5et8cqVhL8cuw=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.13/go.mod h1:kizuDaLX37bG5WZaoxGPQR/LNFXpxp0vsUnqfkWXfNE=
+github.com/aws/aws-sdk-go-v2/service/sesv2 v1.41.5 h1:4Axfv4Ytz7gMiAigzbS3NXWcXRFFHBZB8vFcG7oYRsk=
+github.com/aws/aws-sdk-go-v2/service/sesv2 v1.41.5/go.mod h1:taGBqRDPFzem7/4UB0O8Sua9i1gRXg9fEWgUMKXeunA=
+github.com/aws/aws-sdk-go-v2/service/sso v1.24.15 h1:/eE3DogBjYlvlbhd2ssWyeuovWunHLxfgw3s/OJa4GQ=
+github.com/aws/aws-sdk-go-v2/service/sso v1.24.15/go.mod h1:2PCJYpi7EKeA5SkStAmZlF6fi0uUABuhtF8ILHjGc3Y=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.14 h1:M/zwXiL2iXUrHputuXgmO94TVNmcenPHxgLXLutodKE=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.14/go.mod h1:RVwIw3y/IqxC2YEXSIkAzRDdEU1iRabDPaYjpGCbCGQ=
+github.com/aws/aws-sdk-go-v2/service/sts v1.33.14 h1:TzeR06UCMUq+KA3bDkujxK1GVGy+G8qQN/QVYzGLkQE=
+github.com/aws/aws-sdk-go-v2/service/sts v1.33.14/go.mod h1:dspXf/oYWGWo6DEvj98wpaTeqt5+DMidZD0A9BYTizc=
+github.com/aws/smithy-go v1.22.2 h1:6D9hW43xKFrRx/tXXfAlIZc4JI+yQe6snnWcQyxSyLQ=
+github.com/aws/smithy-go v1.22.2/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
+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/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
+github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
+github.com/getsentry/sentry-go v0.31.1 h1:ELVc0h7gwyhnXHDouXkhqTFSO5oslsRDk0++eyE0KJ4=
+github.com/getsentry/sentry-go v0.31.1/go.mod h1:CYNcMMz73YigoHljQRG+qPF+eMq8gG72XcGN/p71BAY=
+github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
+github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
+github.com/gofiber/fiber/v3 v3.0.0-beta.4 h1:KzDSavvhG7m81NIsmnu5l3ZDbVS4feCidl4xlIfu6V0=
+github.com/gofiber/fiber/v3 v3.0.0-beta.4/go.mod h1:/WFUoHRkZEsGHyy2+fYcdqi109IVOFbVwxv1n1RU+kk=
+github.com/gofiber/schema v1.2.0 h1:j+ZRrNnUa/0ZuWrn/6kAtAufEr4jCJ+JuTURAMxNSZg=
+github.com/gofiber/schema v1.2.0/go.mod h1:YYwj01w3hVfaNjhtJzaqetymL56VW642YS3qZPhuE6c=
+github.com/gofiber/utils/v2 v2.0.0-beta.7 h1:NnHFrRHvhrufPABdWajcKZejz9HnCWmT/asoxRsiEbQ=
+github.com/gofiber/utils/v2 v2.0.0-beta.7/go.mod h1:J/M03s+HMdZdvhAeyh76xT72IfVqBzuz/OJkrMa7cwU=
+github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
+github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+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/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-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
+github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
+github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
+github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
+github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
+github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
+github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
+github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
+github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
+github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
+github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
+github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
+github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
+github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
+github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
+github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
+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=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
+github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
+github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1GshSTtih8C2gDs04w8dReiOGXrGLNoY=
+github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
+github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
+github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
+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/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=
+github.com/tinylib/msgp v1.2.5 h1:WeQg1whrXRFiZusidTQqzETkRpGjFjcIhW6uqWH09po=
+github.com/tinylib/msgp v1.2.5/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0=
+github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
+github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
+github.com/valyala/fasthttp v1.58.0 h1:GGB2dWxSbEprU9j0iMJHgdKYJVDyjrOwF9RE59PbRuE=
+github.com/valyala/fasthttp v1.58.0/go.mod h1:SYXvHHaFp7QZHGKSHmoMipInhrI5StHrhDTYVEjK/Kw=
+github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
+github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
+github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
+github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
+github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
+github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
+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.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
+golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
+golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
+golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
+golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
+golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+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.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
+golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
+golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
+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=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314=
+gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
+gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
+gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
diff --git a/aws-ses-sender/main.go b/aws-ses-sender/main.go
new file mode 100644
index 0000000000..615c7c52a7
--- /dev/null
+++ b/aws-ses-sender/main.go
@@ -0,0 +1,54 @@
+package main
+
+import (
+	"aws-ses-sender/api"
+	"aws-ses-sender/cmd"
+	"aws-ses-sender/config"
+	"context"
+	"log"
+	"os"
+	"os/signal"
+	"syscall"
+	"time"
+
+	"github.com/getsentry/sentry-go"
+)
+
+func main() {
+	// Sentry
+	if err := sentry.Init(sentry.ClientOptions{
+		Dsn: config.GetEnv("SENTRY_DSN"),
+	}); err != nil {
+		log.Printf("Sentry initialization failed: %v", err)
+	}
+	// Ensure flush before exit
+	defer sentry.Flush(2 * time.Second)
+
+	// Create a context that will be canceled on interrupt
+	ctx, cancel := context.WithCancel(context.Background())
+	defer cancel()
+
+	// Set up a channel to listen for interrupt signals
+	c := make(chan os.Signal, 1)
+	signal.Notify(c, os.Interrupt, syscall.SIGTERM)
+	go func() {
+		<-c
+		cancel()
+	}()
+
+	// In main.go, before exit
+	defer func() {
+		if err := config.CloseDB(); err != nil {
+			log.Printf("Error closing database connection: %v", err)
+		}
+	}()
+
+	// Message Scheduler
+	go cmd.RunScheduler(ctx)
+
+	// Email Sender
+	go cmd.RunSender(ctx)
+
+	// HTTP Server
+	api.Run(ctx)
+}
diff --git a/aws-ses-sender/model/email.go b/aws-ses-sender/model/email.go
new file mode 100644
index 0000000000..d42ab07a9d
--- /dev/null
+++ b/aws-ses-sender/model/email.go
@@ -0,0 +1,50 @@
+package model
+
+import (
+	"aws-ses-sender/config"
+	"time"
+
+	"gorm.io/gorm"
+)
+
+const (
+	EmailMessageStatusCreated    = iota // Creation complete
+	EmailMessageStatusProcessing        // Processing
+	EmailMessageStatusSent              // Sent
+	EmailMessageStatusFailed            // Failed
+	EmailMessageStatusStopped           // Stopped
+)
+
+type Request struct {
+	gorm.Model
+	TopicId     string     `json:"topic_id" gorm:"index;default:'';type:varchar(50)"`
+	MessageId   string     `json:"message_id" gorm:"null;type:varchar(100)"`
+	To          string     `json:"to" gorm:"not null;type:varchar(255)"`
+	Subject     string     `json:"subject" gorm:"not null;type:varchar(255)"`
+	Content     string     `json:"content" gorm:"not null;type:text"`
+	ScheduledAt *time.Time `json:"scheduled_at" gorm:"not null;index;type:timestamp"`
+	Status      int        `json:"status" gorm:"default:0;index;not null;type:smallint"`
+	Error       string     `json:"error" gorm:"null;type:varchar(255)"`
+}
+
+func (m *Request) TableName() string {
+	return "email_requests"
+}
+
+type Result struct {
+	gorm.Model
+	RequestId uint    `json:"request_id" gorm:"index;not null"`
+	Request   Request `json:"request" gorm:"foreignKey:RequestId;references:ID"`
+	Status    string  `json:"status" gorm:"not null;index;type:varchar(50)"`
+	Raw       string  `json:"raw" gorm:"type:json"`
+}
+
+func (m *Result) TableName() string {
+	return "email_results"
+}
+
+func init() {
+	db := config.GetDB()
+	_ = db.AutoMigrate(&Request{})
+	_ = db.AutoMigrate(&Result{})
+}
diff --git a/aws-ses-sender/pkg/aws/ses.go b/aws-ses-sender/pkg/aws/ses.go
new file mode 100644
index 0000000000..b932c93342
--- /dev/null
+++ b/aws-ses-sender/pkg/aws/ses.go
@@ -0,0 +1,78 @@
+package aws
+
+import (
+	"aws-ses-sender/config"
+	"context"
+	"fmt"
+	"strconv"
+
+	"github.com/aws/aws-sdk-go-v2/aws"
+	awsConfig "github.com/aws/aws-sdk-go-v2/config"
+	"github.com/aws/aws-sdk-go-v2/credentials"
+	"github.com/aws/aws-sdk-go-v2/service/sesv2"
+	"github.com/aws/aws-sdk-go-v2/service/sesv2/types"
+)
+
+// SES is a wrapper around the AWS SES client
+type SES struct {
+	Client *sesv2.Client
+}
+
+// NewSESClient creates an email client
+func NewSESClient(ctx context.Context) (*SES, error) {
+	accessKeyID := config.GetEnv("AWS_ACCESS_KEY_ID")
+	secretAccessKey := config.GetEnv("AWS_SECRET_ACCESS_KEY")
+	var cfgOpts []func(*awsConfig.LoadOptions) error
+	cfgOpts = append(cfgOpts, awsConfig.WithRegion(config.GetEnv("AWS_REGION", "ap-northeast-2")))
+
+	// If accessKeyID and secretAccessKey are set, use static credentials
+	if accessKeyID != "" && secretAccessKey != "" {
+		cfgOpts = append(cfgOpts, awsConfig.WithCredentialsProvider(
+			credentials.NewStaticCredentialsProvider(accessKeyID, secretAccessKey, ""),
+		))
+	}
+	cfg, err := awsConfig.LoadDefaultConfig(ctx, cfgOpts...)
+	if err != nil {
+		return nil, err
+	}
+	return &SES{
+		Client: sesv2.NewFromConfig(cfg),
+	}, nil
+}
+
+// SendEmail sends an email
+func (s *SES) SendEmail(ctx context.Context, reqId int, subject, body *string, receivers []string) (string, error) {
+	sender := config.GetEnv("EMAIL_SENDER")
+	input := &sesv2.SendEmailInput{
+		FromEmailAddress: aws.String(sender),
+		Destination: &types.Destination{
+			ToAddresses: receivers,
+		},
+		Content: &types.EmailContent{
+			Simple: &types.Message{
+				Subject: &types.Content{
+					Data: aws.String(*subject),
+				},
+				Body: &types.Body{
+					Html: &types.Content{
+						Data: aws.String(*body),
+					},
+				},
+				Headers: []types.MessageHeader{
+					{
+						Name:  aws.String("X-Request-ID"),
+						Value: aws.String(strconv.Itoa(reqId)),
+					},
+				},
+			},
+		},
+	}
+	result, err := s.Client.SendEmail(ctx, input)
+	if err != nil {
+		return "", err
+	}
+	if result.MessageId == nil {
+		return "", fmt.Errorf("SES returned nil MessageId")
+	}
+	return *result.MessageId, nil
+}


Reviews:
user: login: coderabbitai[bot]
body: **Actionable comments posted: 18**

<details>
<summary>♻️ Duplicate comments (1)</summary><blockquote>

<details>
<summary>aws-ses-sender/cmd/scheduler.go (1)</summary><blockquote>

`15-18`: **Same `EMAIL_RATE` parsing caveat applies here**  
`sendPerSec, _ := strconv.Atoi(sendPerSecStr)` ignores errors and can yield zero, resulting in all batches being skipped. Reuse the robust parsing suggested for `sender.go`.

</blockquote></details>

</blockquote></details>

<details>
<summary>🧹 Nitpick comments (20)</summary><blockquote>

<details>
<summary>aws-ses-sender/Dockerfile (2)</summary><blockquote>

`11-13`: **Dockerfile structure improvements needed.**

The EXPOSE directive should typically come before CMD, and a HEALTHCHECK would improve container orchestration.

```diff
+EXPOSE 80
+
+HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \
+  CMD curl -f http://localhost:80/health || exit 1
+
-CMD ["./aws-ses-sender-go"]
+CMD ["./app"]
-
-EXPOSE 80
```

---

`11-11`: **Binary name inconsistency.**

The binary name "aws-ses-sender-go" differs from the project name "aws-ses-sender" mentioned in the PR description. Consider aligning these for consistency.

</blockquote></details>
<details>
<summary>aws-ses-sender/api/server.go (2)</summary><blockquote>

`4-4`: **Module name inconsistency.**

The import path "aws-ses-sender-go/config" doesn't match the project name "aws-ses-sender" mentioned in the PR description.

---

`16-16`: **Missing documentation for exported function.**

The exported `Run()` function should include a documentation comment explaining its purpose and usage.

```diff
+// Run initializes and starts the Fiber web server with all necessary middleware and routes
 func Run() {
```

</blockquote></details>
<details>
<summary>aws-ses-sender/config/env.go (1)</summary><blockquote>

`14-27`: **LGTM, but consider validating required environment variables**

The environment variable management is well-implemented with thread-safe initialization and default value support. However, it would be beneficial to add a function that validates required environment variables at startup.


```diff
+// ValidateRequiredEnvs checks if all required environment variables are set
+func ValidateRequiredEnvs(keys []string) error {
+	for _, key := range keys {
+		if value := os.Getenv(key); value == "" {
+			return fmt.Errorf("required environment variable %s is not set", key)
+		}
+	}
+	return nil
+}
```

Also, consider documenting the expected environment variables and their defaults for better maintainability.

</blockquote></details>
<details>
<summary>aws-ses-sender/config/db.go (1)</summary><blockquote>

`38-39`: **Make database connection parameters configurable**

The maximum number of open connections is hardcoded to 10. For better flexibility, this value should be configurable through environment variables.

```diff
-		sqlDB.SetMaxOpenConns(10)
+		maxOpenConns := GetEnv("DB_MAX_OPEN_CONNS", "10")
+		maxIdleConns := GetEnv("DB_MAX_IDLE_CONNS", "5")
+		maxLifetime := GetEnv("DB_CONN_MAX_LIFETIME", "1h")
+		
+		maxOpenConnsInt, err := strconv.Atoi(maxOpenConns)
+		if err != nil {
+			log.Printf("Warning: Invalid DB_MAX_OPEN_CONNS value, using default 10: %v", err)
+			maxOpenConnsInt = 10
+		}
+		
+		maxIdleConnsInt, err := strconv.Atoi(maxIdleConns)
+		if err != nil {
+			log.Printf("Warning: Invalid DB_MAX_IDLE_CONNS value, using default 5: %v", err)
+			maxIdleConnsInt = 5
+		}
+		
+		maxLifetimeDuration, err := time.ParseDuration(maxLifetime)
+		if err != nil {
+			log.Printf("Warning: Invalid DB_CONN_MAX_LIFETIME value, using default 1h: %v", err)
+			maxLifetimeDuration = time.Hour
+		}
+		
+		sqlDB.SetMaxOpenConns(maxOpenConnsInt)
+		sqlDB.SetMaxIdleConns(maxIdleConnsInt)
+		sqlDB.SetConnMaxLifetime(maxLifetimeDuration)
```

</blockquote></details>
<details>
<summary>aws-ses-sender/api/route.go (1)</summary><blockquote>

`7-17`: **Add documentation for API endpoints and consider security**

The routes are well-organized and follow RESTful conventions. However, there's no documentation about the expected parameters for each endpoint, and no mention of security considerations.



Consider adding comments explaining:
1. Parameters expected by each endpoint
2. Response formats
3. The authentication/authorization approach used (if any)

Also, verify that using GET for the createOpenEventHandler is intentional (which makes sense for pixel tracking).

```diff
 // setV1Routes V1 Routes
 func setV1Routes(app *fiber.App) {
-	// Messages
+	// Messages - Creates a new email message request
+	// POST /v1/messages
+	// Body: { "to": "recipient@example.com", "subject": "Subject", "content": "Email body", ... }
 	app.Post("/v1/messages", createMessageHandler)
-	// Topics
+	// Topics - Gets count statistics for a specific topic
+	// GET /v1/topics/:topicId
+	// Returns: { "requests": { "total": 100, "sent": 90, ... }, "results": { ... } }
 	app.Get("/v1/topics/:topicId", getResultCountHandler)
-	// Events
+	// Events - Tracks email open events (used as image pixel in emails)
+	// GET /v1/events/open?requestId=123
+	// Returns: 1x1 transparent pixel image
 	app.Get("/v1/events/open", createOpenEventHandler)
+	// Gets count of sent emails in recent time window
+	// GET /v1/events/counts/sent?hours=24
+	// Returns: { "count": 1234 }
 	app.Get("/v1/events/counts/sent", getSentCountHandler)
+	// Receives AWS SNS callbacks with SES delivery results
+	// POST /v1/events/results
+	// Body: AWS SNS notification format
 	app.Post("/v1/events/results", createResultEventHandler)
 }
```

</blockquote></details>
<details>
<summary>aws-ses-sender/cmd/sender.go (1)</summary><blockquote>

`27-29`: **Avoid blocking when the queue is temporarily empty**  
Using `<-reqChan` without a non-blocking select means the ticker goroutine stalls whenever no messages are ready, eliminating the intended rate-limit cadence. Consider:

```go
select {
case req := <-reqChan:
    // send …
default:
    // nothing to send this tick
}
```
This keeps the ticker free-running and enforces the per-second rate even with bursty traffic.

</blockquote></details>
<details>
<summary>aws-ses-sender/model/email.go (2)</summary><blockquote>

`38-40`: **PostgreSQL prefers `jsonb` – switch column type for better indexing**

`type:json` maps to the legacy `json` type; `jsonb` is generally recommended because it supports GIN/GIST indexes and equality checks. Unless you have a strong reason to keep the textual form, switch to `jsonb`.

```diff
-Raw       string  `json:"raw" gorm:"null;type:json"`
+Raw       string  `json:"raw" gorm:"null;type:jsonb"`
```

---

`46-50`: **Running migrations inside `init()` couples package import with DDL**

Placing `AutoMigrate` in an `init()` means every test or binary importing `model` will implicitly touch the database.  
Consider exposing an explicit `model.Migrate(db *gorm.DB)` function and calling it from `main()` during service start-up. This improves testability and avoids unexpected connection attempts in unrelated binaries.

</blockquote></details>
<details>
<summary>aws-ses-sender/api/handler.go (5)</summary><blockquote>

`20-33`: **`c.Bind().JSON` error handling is fine – but the HTTP 400 body leaks the raw error**

Returning `err.Error()` directly may expose internal struct field names or validation hints. Wrap it with a friendly message instead, logging the original error on the server side.

```diff
-    return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
+    log.Printf("Invalid request body: %v", err)
+    return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request payload"})
```

---

`61-70`: **Return value should reflect inserted rows, not message groups**

You pre-calculate `totCnt` but never return it. Exposing the real number of queued emails is more useful to clients monitoring quota usage.

```diff
-    "count":   len(reqBody.Messages),
+    "count":   totCnt,
```

---

`108-128`: **Automatically confirm SNS subscription instead of requiring manual action**

The service will not receive notifications until the SubscribeURL is visited. Automating this step avoids operational surprises.

```diff
if reqBody.Type == "SubscriptionConfirmation" {
-    log.Printf("Subscription confirmation required. Visiting: %s", reqBody.SubscribeURL)
-    return c.JSON(fiber.Map{"message": "Subscription confirmation required"})
+    go func(url string) {
+        if _, err := http.Get(url); err != nil {
+            log.Printf("Failed to confirm subscription: %v", err)
+        }
+    }(reqBody.SubscribeURL)
+    return c.JSON(fiber.Map{"message": "subscription confirmed"})
}
```

---

`197-228`: **Missing `Processing` branch leads to under-reported counts**

The status constants include `EmailMessageStatusProcessing`, but the switch omits it, so the `processing` figure is always zero.

```diff
 type reqCnts struct {
     Total      int `json:"total"`
     Created    int `json:"created"`
+    Processing int `json:"processing"`
     Sent       int `json:"sent"`
     Failed     int `json:"failed"`
     Stopped    int `json:"stopped"`
 }

 ...

 case model.EmailMessageStatusProcessing:
     reqCnts.Processing = r.Count
```

---

`264-280`: **`updated_at` filter may miss late deliveries; prefer `sent_at` or status timestamp**

Relying on `updated_at` assumes *nothing else* touches the record after it is marked `sent`. If you later update the record (e.g. to store opens), the same row will be re-counted.  
Consider storing a dedicated `sent_at` timestamp when the sender marks the message as sent, and filter on that column instead.

</blockquote></details>
<details>
<summary>aws-ses-sender/README.md (5)</summary><blockquote>

`11-12`: **Use hyphen in “open-source project”**  
The phrase “open source project” should be hyphenated when used as a compound adjective.

<details>
<summary>🧰 Tools</summary>

<details>
<summary>🪛 LanguageTool</summary>

[uncategorized] ~11-~11: If this is a compound adjective that modifies the following noun, use a hyphen.
Context: ...he basic sending functionality from [my open source project](https://github.com/lee-lou2/aw...

(EN_COMPOUND_ADJECTIVE_INTERNAL)

</details>

</details>

---

`2-4`: **Prefer “Go” over “Golang” for consistency**  
The official language name is “Go.” Consider changing title/keywords/description to use “Go” (or “Go (Golang)”) for consistency and discoverability.

---

`13-19`: **Add Sentry to Features list**  
Since you support optional Sentry error tracking (see env vars and `main.go`), it’s helpful to call this out under **Features** so users know it’s built-in.

---

`22-35`: **Add caption or alt text for Mermaid diagram**  
The flowchart is clear, but consider a brief sentence or caption before the mermaid block to explain what the diagram shows (e.g., “Data flow between client, API, scheduler, sender, SES, and tracking services”). This improves readability and accessibility.

---

`138-141`: **Specify language for API endpoint snippets**  
For clarity and syntax highlighting, add a language tag to the code fences, e.g.:  
```diff
- ```
+ ```http
  POST /v1/messages
  ```
```

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between e0211793aeb344dfa1247aa23ba319cd9e5de763 and 10db7b81d1702d59ec8d304fbf73689ddc85d359.

</details>

<details>
<summary>⛔ Files ignored due to path filters (2)</summary>

* `aws-ses-sender/go.mod` is excluded by `!**/*.mod`
* `aws-ses-sender/go.sum` is excluded by `!**/*.sum`, `!**/*.sum`

</details>

<details>
<summary>📒 Files selected for processing (14)</summary>

* `README.md` (1 hunks)
* `aws-ses-sender/.gitignore` (1 hunks)
* `aws-ses-sender/Dockerfile` (1 hunks)
* `aws-ses-sender/README.md` (1 hunks)
* `aws-ses-sender/api/handler.go` (1 hunks)
* `aws-ses-sender/api/route.go` (1 hunks)
* `aws-ses-sender/api/server.go` (1 hunks)
* `aws-ses-sender/cmd/scheduler.go` (1 hunks)
* `aws-ses-sender/cmd/sender.go` (1 hunks)
* `aws-ses-sender/config/db.go` (1 hunks)
* `aws-ses-sender/config/env.go` (1 hunks)
* `aws-ses-sender/main.go` (1 hunks)
* `aws-ses-sender/model/email.go` (1 hunks)
* `aws-ses-sender/pkg/aws/ses.go` (1 hunks)

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🧬 Code Graph Analysis (7)</summary>

<details>
<summary>aws-ses-sender/main.go (4)</summary><blockquote>

<details>
<summary>aws-ses-sender/config/env.go (1)</summary>

* `GetEnv` (14-27)

</details>
<details>
<summary>aws-ses-sender/cmd/scheduler.go (1)</summary>

* `RunScheduler` (13-56)

</details>
<details>
<summary>aws-ses-sender/cmd/sender.go (1)</summary>

* `RunSender` (16-58)

</details>
<details>
<summary>aws-ses-sender/api/server.go (1)</summary>

* `Run` (16-39)

</details>

</blockquote></details>
<details>
<summary>aws-ses-sender/config/db.go (1)</summary><blockquote>

<details>
<summary>aws-ses-sender/config/env.go (1)</summary>

* `GetEnv` (14-27)

</details>

</blockquote></details>
<details>
<summary>aws-ses-sender/api/server.go (1)</summary><blockquote>

<details>
<summary>aws-ses-sender/config/env.go (1)</summary>

* `GetEnv` (14-27)

</details>

</blockquote></details>
<details>
<summary>aws-ses-sender/cmd/scheduler.go (3)</summary><blockquote>

<details>
<summary>aws-ses-sender/config/db.go (1)</summary>

* `GetDB` (19-42)

</details>
<details>
<summary>aws-ses-sender/config/env.go (1)</summary>

* `GetEnv` (14-27)

</details>
<details>
<summary>aws-ses-sender/model/email.go (3)</summary>

* `Request` (18-28)
* `EmailMessageStatusProcessing` (12-12)
* `EmailMessageStatusCreated` (11-11)

</details>

</blockquote></details>
<details>
<summary>aws-ses-sender/cmd/sender.go (4)</summary><blockquote>

<details>
<summary>aws-ses-sender/model/email.go (3)</summary>

* `Request` (18-28)
* `EmailMessageStatusSent` (13-13)
* `EmailMessageStatusFailed` (14-14)

</details>
<details>
<summary>aws-ses-sender/config/env.go (1)</summary>

* `GetEnv` (14-27)

</details>
<details>
<summary>aws-ses-sender/pkg/aws/ses.go (1)</summary>

* `NewSESClient` (21-37)

</details>
<details>
<summary>aws-ses-sender/config/db.go (1)</summary>

* `GetDB` (19-42)

</details>

</blockquote></details>
<details>
<summary>aws-ses-sender/api/handler.go (2)</summary><blockquote>

<details>
<summary>aws-ses-sender/config/db.go (1)</summary>

* `GetDB` (19-42)

</details>
<details>
<summary>aws-ses-sender/model/email.go (6)</summary>

* `Request` (18-28)
* `EmailMessageStatusCreated` (11-11)
* `Result` (34-40)
* `EmailMessageStatusSent` (13-13)
* `EmailMessageStatusFailed` (14-14)
* `EmailMessageStatusStopped` (15-15)

</details>

</blockquote></details>
<details>
<summary>aws-ses-sender/model/email.go (1)</summary><blockquote>

<details>
<summary>aws-ses-sender/config/db.go (1)</summary>

* `GetDB` (19-42)

</details>

</blockquote></details>

</details><details>
<summary>🪛 LanguageTool</summary>

<details>
<summary>README.md</summary>

[style] ~24-~24: Three successive sentences begin with the same word. Consider rewording the sentence or use a thesaurus to find a synonym.
Context: ...erverless applications with AWS SAM. - [AWS SES Email Sender](./aws-ses-sender/READ...

(ENGLISH_WORD_REPEAT_BEGINNING_RULE)

</details>
<details>
<summary>aws-ses-sender/README.md</summary>

[uncategorized] ~11-~11: If this is a compound adjective that modifies the following noun, use a hyphen.
Context: ...he basic sending functionality from [my open source project](https://github.com/lee-lou2/aw...

(EN_COMPOUND_ADJECTIVE_INTERNAL)

</details>

</details>

</details>

<details>
<summary>🔇 Additional comments (5)</summary><blockquote>

<details>
<summary>aws-ses-sender/.gitignore (1)</summary>

`1-2`: **Appropriate gitignore entries for development.**

The gitignore entries properly exclude IDE settings and environment files containing sensitive information from version control.

</details>
<details>
<summary>README.md (1)</summary>

`24-24`: **Well-structured README entry.**

The new AWS SES Email Sender entry in the Table of Contents follows the established pattern and provides a clear, informative description of the recipe's functionality.

<details>
<summary>🧰 Tools</summary>

<details>
<summary>🪛 LanguageTool</summary>

[style] ~24-~24: Three successive sentences begin with the same word. Consider rewording the sentence or use a thesaurus to find a synonym.
Context: ...erverless applications with AWS SAM. - [AWS SES Email Sender](./aws-ses-sender/READ...

(ENGLISH_WORD_REPEAT_BEGINNING_RULE)

</details>

</details>

</details>
<details>
<summary>aws-ses-sender/Dockerfile (1)</summary>

`1-1`: _⚠️ Potential issue_

**Invalid Go version specified.**

`golang:1.23` doesn't exist yet. The latest stable version as of May 2024 is Go 1.22.x.

```diff
-FROM golang:1.23
+FROM golang:1.22
```



> Likely an incorrect or invalid review comment.

</details>
<details>
<summary>aws-ses-sender/api/server.go (1)</summary>

`27-27`: **Security concern with pprof middleware.**

The pprof middleware exposes runtime profiling data, which could be a security risk in production if not properly protected.

Consider restricting access to pprof endpoints or conditionally enabling it only in development environments:

```diff
-app.Use(pprof.New())
+// Only enable pprof in development environment
+if config.GetEnv("APP_ENV", "development") != "production" {
+  app.Use(pprof.New())
+}
```

</details>
<details>
<summary>aws-ses-sender/cmd/sender.go (1)</summary>

`31-34`: **Sanitize HTML before injecting open-tracking pixels**  
Blind string concatenation on `r.Content` may double-inject `<html>`, break existing tags or allow malicious HTML/XSS if `Content` is user-supplied. At minimum, ensure the pixel is appended just before `</body>` or encode untrusted values.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T04:55:43Z
user: login: ReneWerner87
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-07T07:00:03Z
user: login: lee-lou2
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T09:42:17Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 0**

<details>
<summary>♻️ Duplicate comments (1)</summary><blockquote>

<details>
<summary>aws-ses-sender/Dockerfile (1)</summary><blockquote>

`1-21`: **Multi-stage build implemented correctly**

The Dockerfile now cleanly separates the build and runtime stages, removing Go toolchain overhead from the final image and significantly reducing size. This addresses the earlier recommendation.

</blockquote></details>

</blockquote></details>

<details>
<summary>🧹 Nitpick comments (3)</summary><blockquote>

<details>
<summary>aws-ses-sender/Dockerfile (3)</summary><blockquote>

`10-10`: **Strip debug symbols to further reduce binary size**

Consider adding `-ldflags "-s -w"` to the `go build` invocation to strip symbol tables and debug information:

```diff
 RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo \
-  -o app .
+  -ldflags "-s -w" \
+  -o app .
```

---

`12-18`: **Consider using a scratch or distroless image for an even smaller runtime**

Since the binary is statically linked, you can eliminate Alpine entirely by switching to `scratch` or a distroless base and copying in only the CA bundle:

```diff
-FROM alpine:3.21
-RUN apk --no-cache add ca-certificates
+FROM scratch
+# Copy CA bundle for TLS verification
+COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
```

---

`16-19`: **Run the application as a non-root user for improved security**

Dropping root privileges reduces attack surface. In the final stage, add and switch to a dedicated user:

```diff
 WORKDIR /root/
 COPY --from=builder /usr/src/app/app .
+RUN addgroup -S appgroup && adduser -S appuser -G appgroup
+USER appuser
```

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 10db7b81d1702d59ec8d304fbf73689ddc85d359 and 3ac0d4a6239e00b1692386f471085244f6bf6d24.

</details>

<details>
<summary>📒 Files selected for processing (1)</summary>

* `aws-ses-sender/Dockerfile` (1 hunks)

</details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T09:42:58Z
user: login: coderabbitai[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T09:44:29Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 0**

<details>
<summary>🧹 Nitpick comments (8)</summary><blockquote>

<details>
<summary>aws-ses-sender/README.md (8)</summary><blockquote>

`11-11`: **Hyphenate “open-source project”**  
The phrase “open source project” is used as a compound adjective; consider hyphenating it to “open-source project” for clarity and consistency.  
```diff
- ... from [my open source project](https://github.com/lee-lou2/aws-ses-sender-go)
+ ... from [my open-source project](https://github.com/lee-lou2/aws-ses-sender-go)
```

<details>
<summary>🧰 Tools</summary>

<details>
<summary>🪛 LanguageTool</summary>

[uncategorized] ~11-~11: If this is a compound adjective that modifies the following noun, use a hyphen.
Context: ...he basic sending functionality from [my open source project](https://github.com/lee-lou2/aw...

(EN_COMPOUND_ADJECTIVE_INTERNAL)

</details>

</details>

---

`49-54`: **Provide IAM permission details**  
The step “Grant following permissions to IAM user” ends without listing the required policies or actions. To ensure users can follow along, add the specific SES/SNS IAM permissions (e.g., `ses:SendEmail`, `ses:SendRawEmail`, `sns:Publish`, plus any required resource ARNs).

---

`56-74`: **Specify language for project structure code block**  
The fenced block under **Project Structure** has no language identifier, which triggers markdownlint (MD040). Consider adding a language, e.g., `plaintext`, to enable proper formatting:

```diff
- ```
+ ```plaintext
```

<details>
<summary>🧰 Tools</summary>

<details>
<summary>🪛 markdownlint-cli2 (0.17.2)</summary>

56-56: Fenced code blocks should have a language specified
null

(MD040, fenced-code-language)

</details>

</details>

---

`138-140`: **Add language specifier for HTTP endpoint**  
The code block showing `POST /v1/messages` is untagged. Specify a language like `http` (or `bash`) to enable syntax highlighting:

```diff
- ```
+ ```http
```

<details>
<summary>🧰 Tools</summary>

<details>
<summary>🪛 markdownlint-cli2 (0.17.2)</summary>

138-138: Fenced code blocks should have a language specified
null

(MD040, fenced-code-language)

</details>

</details>

---

`158-160`: **Add language specifier for HTTP endpoint**  
The block for `GET /v1/topics/:topicId` should include a language marker (`http`) for clarity:

```diff
- ```
+ ```http
```

<details>
<summary>🧰 Tools</summary>

<details>
<summary>🪛 markdownlint-cli2 (0.17.2)</summary>

158-158: Fenced code blocks should have a language specified
null

(MD040, fenced-code-language)

</details>

</details>

---

`163-165`: **Add language specifier for HTTP endpoint**  
Specify `http` on the fence for the `GET /v1/events/open?requestId={requestId}` snippet to improve readability:

```diff
- ```
+ ```http
```

<details>
<summary>🧰 Tools</summary>

<details>
<summary>🪛 markdownlint-cli2 (0.17.2)</summary>

163-163: Fenced code blocks should have a language specified
null

(MD040, fenced-code-language)

</details>

</details>

---

`168-170`: **Add language specifier for HTTP endpoint**  
The snippet `GET /v1/events/counts/sent?hours={hours}` is missing a language tag. Use `http`:

```diff
- ```
+ ```http
```

<details>
<summary>🧰 Tools</summary>

<details>
<summary>🪛 markdownlint-cli2 (0.17.2)</summary>

168-168: Fenced code blocks should have a language specified
null

(MD040, fenced-code-language)

</details>

</details>

---

`173-175`: **Add language specifier for HTTP endpoint**  
The `POST /v1/events/results` block is untagged. Prefix with `http` for consistent formatting:

```diff
- ```
+ ```http
```

<details>
<summary>🧰 Tools</summary>

<details>
<summary>🪛 markdownlint-cli2 (0.17.2)</summary>

173-173: Fenced code blocks should have a language specified
null

(MD040, fenced-code-language)

</details>

</details>

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 3ac0d4a6239e00b1692386f471085244f6bf6d24 and d07017e2bd3fe773486d9d109b58bfd82f264398.

</details>

<details>
<summary>⛔ Files ignored due to path filters (1)</summary>

* `aws-ses-sender/go.mod` is excluded by `!**/*.mod`

</details>

<details>
<summary>📒 Files selected for processing (8)</summary>

* `aws-ses-sender/README.md` (1 hunks)
* `aws-ses-sender/api/handler.go` (1 hunks)
* `aws-ses-sender/api/server.go` (1 hunks)
* `aws-ses-sender/cmd/scheduler.go` (1 hunks)
* `aws-ses-sender/cmd/sender.go` (1 hunks)
* `aws-ses-sender/main.go` (1 hunks)
* `aws-ses-sender/model/email.go` (1 hunks)
* `aws-ses-sender/pkg/aws/ses.go` (1 hunks)

</details>

<details>
<summary>🚧 Files skipped from review as they are similar to previous changes (7)</summary>

* aws-ses-sender/cmd/sender.go
* aws-ses-sender/api/server.go
* aws-ses-sender/main.go
* aws-ses-sender/pkg/aws/ses.go
* aws-ses-sender/cmd/scheduler.go
* aws-ses-sender/model/email.go
* aws-ses-sender/api/handler.go

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🪛 LanguageTool</summary>

<details>
<summary>aws-ses-sender/README.md</summary>

[uncategorized] ~11-~11: If this is a compound adjective that modifies the following noun, use a hyphen.
Context: ...he basic sending functionality from [my open source project](https://github.com/lee-lou2/aw...

(EN_COMPOUND_ADJECTIVE_INTERNAL)

</details>

</details>
<details>
<summary>🪛 markdownlint-cli2 (0.17.2)</summary>

<details>
<summary>aws-ses-sender/README.md</summary>

56-56: Fenced code blocks should have a language specified
null

(MD040, fenced-code-language)

---

138-138: Fenced code blocks should have a language specified
null

(MD040, fenced-code-language)

---

158-158: Fenced code blocks should have a language specified
null

(MD040, fenced-code-language)

---

163-163: Fenced code blocks should have a language specified
null

(MD040, fenced-code-language)

---

168-168: Fenced code blocks should have a language specified
null

(MD040, fenced-code-language)

---

173-173: Fenced code blocks should have a language specified
null

(MD040, fenced-code-language)

</details>

</details>

</details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T09:48:11Z
user: login: SeungGuk-Ezllabs
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T13:27:12Z
user: login: coderabbitai[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T13:28:05Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 3**

<details>
<summary>🧹 Nitpick comments (9)</summary><blockquote>

<details>
<summary>aws-ses-sender/api/middlewares.go (1)</summary><blockquote>

`11-19`: **Add empty API key validation and improve error message**

The current implementation doesn't validate if the expected API key is empty. This could lead to authentication being bypassed if the API_KEY environment variable isn't properly set.


```diff
func apiKeyAuth(c fiber.Ctx) error {
	apiKey := c.Get("x-api-key")
	expectedAPIKey := config.GetEnv("API_KEY", "")
+	
+	if expectedAPIKey == "" {
+		log.Println("Warning: API_KEY environment variable is not set")
+	}
	
	if apiKey != expectedAPIKey {
		return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
-			"error": "Unauthorized",
+			"error": "Unauthorized: Invalid API key",
		})
	}
	return c.Next()
}
```

</blockquote></details>
<details>
<summary>aws-ses-sender/cmd/scheduler.go (3)</summary><blockquote>

`27-28`: **Add batch size safety check**

The loop processes email requests in batches, but there's no safety check to prevent potential issues if `sendPerMin` is extraordinarily large.


```diff
	for {
		select {
		case <-ctx.Done():
			return
		case <-ticker.C:
+			// Apply a reasonable upper limit to avoid excessive processing
+			remainingToProcess := sendPerMin
+			if remainingToProcess > 100000 {
+				log.Printf("Warning: Capping excessive process rate of %d to 100000 per minute", sendPerMin)
+				remainingToProcess = 100000
+			}
-			for i := 0; i < sendPerMin; i += batchSize {
+			for i := 0; i < remainingToProcess; i += batchSize {
				now := time.Now()
```

---

`52-54`: **Improve error logging with structured information**

The current error logging is minimal. Consider adding more context to help with debugging.


```diff
				if err != nil {
-					log.Printf("Update Returning Error: %v", err)
+					log.Printf("Error updating email requests: %v (batch starting at index: %d, batchSize: %d, time: %s)", 
+						err, i, batchSize, now.Format(time.RFC3339))
				} else if len(reqs) > 0 {
```

---

`55-57`: **Consider channel buffer overflow protection**

The code sends requests to a channel without checking if it's full or if it has capacity, which could potentially block indefinitely if the consumer isn't keeping pace.


```diff
				} else if len(reqs) > 0 {
+					log.Printf("Processing %d email requests", len(reqs))
					for _, req := range reqs {
-						reqChan <- req
+						// Add non-blocking send with timeout
+						select {
+						case reqChan <- req:
+							// Successfully sent to channel
+						case <-time.After(100 * time.Millisecond):
+							// Channel might be full, log and break
+							log.Printf("Channel send timeout - reqChan might be full, processed %d/%d requests",
+								req.ID, len(reqs))
+							break
+						}
					}
```

</blockquote></details>
<details>
<summary>aws-ses-sender/README.md (5)</summary><blockquote>

`11-11`: **Add hyphen to "open-source" compound adjective**

Minor grammar fix: "open source" should be hyphenated when used as a compound adjective.


```diff
-This is an AWS SES-based Golang email delivery service that extracts only the basic sending functionality from [my open source project](https://github.com/lee-lou2/aws-ses-sender-go).
+This is an AWS SES-based Golang email delivery service that extracts only the basic sending functionality from [my open-source project](https://github.com/lee-lou2/aws-ses-sender-go).
```

<details>
<summary>🧰 Tools</summary>

<details>
<summary>🪛 LanguageTool</summary>

[uncategorized] ~11-~11: If this is a compound adjective that modifies the following noun, use a hyphen.
Context: ...he basic sending functionality from [my open source project](https://github.com/lee-lou2/aw...

(EN_COMPOUND_ADJECTIVE_INTERNAL)

</details>

</details>

---

`56-74`: **Add language specifiers to code blocks**

Add language specifiers to the code block for proper syntax highlighting.


```diff
-```
+```plaintext
 aws-ses-sender/
 ├── main.go              # Application entry point
 ├── api/                 # HTTP API related code
```

<details>
<summary>🧰 Tools</summary>

<details>
<summary>🪛 markdownlint-cli2 (0.17.2)</summary>

56-56: Fenced code blocks should have a language specified
null

(MD040, fenced-code-language)

</details>

</details>

---

`139-141`: **Add language specifiers to all API endpoint code blocks**

Add language specifiers to all API endpoint code blocks for proper syntax highlighting.


```diff
-```
+```http
 POST /v1/messages
 ```
```

This applies to all other API endpoint code blocks as well (lines 159-161, 164-166, 169-171, 174-176).

<details>
<summary>🧰 Tools</summary>

<details>
<summary>🪛 markdownlint-cli2 (0.17.2)</summary>

139-139: Fenced code blocks should have a language specified
null

(MD040, fenced-code-language)

</details>

</details>

---

`109-109`: **Fix timezone format in example**

The timezone format in the example configuration has inappropriate capitalization.


```diff
-DB_TIMEZONE=Asia/seoul
+DB_TIMEZONE=Asia/Seoul
```

---

`121-124`: **Improve repository clone instructions**

The clone instructions use a placeholder URL that should be replaced with the actual repository URL.


```diff
1. Clone repository:
   ```bash
-   git clone <repository_URL>
+   git clone https://github.com/gofiber/recipes.git
+   # Navigate to the specific recipe
    cd aws-ses-sender
   ```
```

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between d07017e2bd3fe773486d9d109b58bfd82f264398 and 6c2bdcc16d64e9678626b896b64f0cb4a940e368.

</details>

<details>
<summary>📒 Files selected for processing (11)</summary>

* `aws-ses-sender/README.md` (1 hunks)
* `aws-ses-sender/api/handler.go` (1 hunks)
* `aws-ses-sender/api/middlewares.go` (1 hunks)
* `aws-ses-sender/api/route.go` (1 hunks)
* `aws-ses-sender/api/server.go` (1 hunks)
* `aws-ses-sender/cmd/scheduler.go` (1 hunks)
* `aws-ses-sender/cmd/sender.go` (1 hunks)
* `aws-ses-sender/config/db.go` (1 hunks)
* `aws-ses-sender/main.go` (1 hunks)
* `aws-ses-sender/model/email.go` (1 hunks)
* `aws-ses-sender/pkg/aws/ses.go` (1 hunks)

</details>

<details>
<summary>🚧 Files skipped from review as they are similar to previous changes (8)</summary>

* aws-ses-sender/api/route.go
* aws-ses-sender/cmd/sender.go
* aws-ses-sender/api/server.go
* aws-ses-sender/main.go
* aws-ses-sender/config/db.go
* aws-ses-sender/pkg/aws/ses.go
* aws-ses-sender/model/email.go
* aws-ses-sender/api/handler.go

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🪛 LanguageTool</summary>

<details>
<summary>aws-ses-sender/README.md</summary>

[uncategorized] ~11-~11: If this is a compound adjective that modifies the following noun, use a hyphen.
Context: ...he basic sending functionality from [my open source project](https://github.com/lee-lou2/aw...

(EN_COMPOUND_ADJECTIVE_INTERNAL)

</details>

</details>
<details>
<summary>🪛 markdownlint-cli2 (0.17.2)</summary>

<details>
<summary>aws-ses-sender/README.md</summary>

56-56: Fenced code blocks should have a language specified
null

(MD040, fenced-code-language)

---

139-139: Fenced code blocks should have a language specified
null

(MD040, fenced-code-language)

---

159-159: Fenced code blocks should have a language specified
null

(MD040, fenced-code-language)

---

164-164: Fenced code blocks should have a language specified
null

(MD040, fenced-code-language)

---

169-169: Fenced code blocks should have a language specified
null

(MD040, fenced-code-language)

---

174-174: Fenced code blocks should have a language specified
null

(MD040, fenced-code-language)

</details>

</details>

</details>

<details>
<summary>🔇 Additional comments (1)</summary><blockquote>

<details>
<summary>aws-ses-sender/cmd/scheduler.go (1)</summary>

`3-10`: <details>
<summary>✅ Verification successful</summary>

**Verify reqChan is properly declared elsewhere**

The `reqChan` variable is used but not declared in this file. Make sure it's properly declared and initialized in a shared location.

---

🏁 Script executed:

```shell
#!/bin/bash
# Search for reqChan declaration in the codebase
rg -A 2 -B 2 "reqChan\s*:?=" --type go
```

Length of output: 270

---



</details>

**reqChan Declaration Verified**

The `reqChan` channel is declared at the package level in `aws-ses-sender/cmd/sender.go`, making it accessible in `aws-ses-sender/cmd/scheduler.go`:

- aws-ses-sender/cmd/sender.go:  
  ```go
  var reqChan = make(chan *model.Request, 1000)
  ```

No further changes required.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T13:30:00Z
user: login: SeungGuk-Ezllabs
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T13:30:11Z
user: login: SeungGuk-Ezllabs
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T13:30:59Z
user: login: coderabbitai[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T13:31:26Z
user: login: coderabbitai[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T13:32:00Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 3**

<details>
<summary>🧹 Nitpick comments (1)</summary><blockquote>

<details>
<summary>aws-ses-sender/api/handler.go (1)</summary><blockquote>

`271-277`: **Unbounded `hours` parameter could trigger heavy queries**

A client may request an arbitrarily large window (`?hours=876000` ≈ 100 years).  
That forces a full-table scan and inflates response times.

Guard the input, e.g.:

```diff
-hours, err := strconv.Atoi(c.Query("hours", "24"))
+hours, err := strconv.Atoi(c.Query("hours", "24"))
+if err != nil || hours <= 0 || hours > 168 { // cap at 7 days
+    return c.Status(fiber.StatusBadRequest).
+        JSON(fiber.Map{"error": "hours must be 1-168"})
+}
```

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 6c2bdcc16d64e9678626b896b64f0cb4a940e368 and 57b539805d58301d3bf162b9f374505cbaf1739b.

</details>

<details>
<summary>📒 Files selected for processing (6)</summary>

* `aws-ses-sender/api/handler.go` (1 hunks)
* `aws-ses-sender/cmd/scheduler.go` (1 hunks)
* `aws-ses-sender/cmd/sender.go` (1 hunks)
* `aws-ses-sender/config/db.go` (1 hunks)
* `aws-ses-sender/model/email.go` (1 hunks)
* `aws-ses-sender/pkg/aws/ses.go` (1 hunks)

</details>

<details>
<summary>🚧 Files skipped from review as they are similar to previous changes (5)</summary>

* aws-ses-sender/pkg/aws/ses.go
* aws-ses-sender/cmd/sender.go
* aws-ses-sender/config/db.go
* aws-ses-sender/cmd/scheduler.go
* aws-ses-sender/model/email.go

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🧬 Code Graph Analysis (1)</summary>

<details>
<summary>aws-ses-sender/api/handler.go (2)</summary><blockquote>

<details>
<summary>aws-ses-sender/config/db.go (1)</summary>

* `GetDB` (19-42)

</details>
<details>
<summary>aws-ses-sender/model/email.go (6)</summary>

* `Request` (18-28)
* `EmailMessageStatusCreated` (11-11)
* `Result` (34-40)
* `EmailMessageStatusSent` (13-13)
* `EmailMessageStatusFailed` (14-14)
* `EmailMessageStatusStopped` (15-15)

</details>

</blockquote></details>

</details>

</details>

<details>
<summary>🔇 Additional comments (1)</summary><blockquote>

<details>
<summary>aws-ses-sender/api/handler.go (1)</summary>

`32-34`: **Possible compile-time breakage – verify the Fiber v3 binding API**

`c.Bind().JSON(&reqBody)` compiles only with the new experimental binding helper introduced in very recent Fiber v3 nightlies.  
If the project is built with an older v3 tag – or if the authors decide to stay on the stable v2 line – this call won’t exist and the whole service will fail to build.

```go
// v2 (stable) style
if err := c.BodyParser(&reqBody); err != nil { … }

// v3 (latest nightly) style
if err := c.Bind().Body(&reqBody); err != nil { … }
```

Please confirm the exact Fiber version pinned in `go.mod` and adjust the API call (or add a replace directive) accordingly.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T23:16:33Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 0**

<details>
<summary>♻️ Duplicate comments (1)</summary><blockquote>

<details>
<summary>aws-ses-sender/README.md (1)</summary><blockquote>

`53-54`: _🛠️ Refactor suggestion_

**Add detailed IAM permissions**

The IAM permissions section is currently blank. Please list the exact AWS actions the service requires. For example:
```diff
 4. Grant following permissions to IAM user
+   - `ses:SendEmail`
+   - `ses:SendRawEmail`
+   - `sns:Publish`   (for delivery notifications)
+   - `sns:Subscribe` (for delivery notifications)
```

</blockquote></details>

</blockquote></details>

<details>
<summary>🧹 Nitpick comments (2)</summary><blockquote>

<details>
<summary>aws-ses-sender/README.md (2)</summary><blockquote>

`11-11`: **Hyphenate compound adjective for consistency**

Use a hyphen in “open-source project” to form a proper compound adjective.  
Apply this diff:
```diff
- This is an AWS SES-based Golang email delivery service that extracts only the basic sending functionality from [my open source project](https://github.com/lee-lou2/aws-ses-sender-go).
+ This is an AWS SES-based Golang email delivery service that extracts only the basic sending functionality from [my open-source project](https://github.com/lee-lou2/aws-ses-sender-go).
```

<details>
<summary>🧰 Tools</summary>

<details>
<summary>🪛 LanguageTool</summary>

[uncategorized] ~11-~11: If this is a compound adjective that modifies the following noun, use a hyphen.
Context: ...he basic sending functionality from [my open source project](https://github.com/lee-lou2/aw...

(EN_COMPOUND_ADJECTIVE_INTERNAL)

</details>

</details>

---

`20-20`: **Promote FlowChart to top-level section**

The flowchart is a key architectural overview and should be introduced as a top-level section (`##`) rather than a subsection. Also standardize the casing to “Flowchart.”  
Apply this diff:
```diff
- ### FlowChart
+ ## Flowchart
```

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 57b539805d58301d3bf162b9f374505cbaf1739b and d7a3fbb246fb8fc536794e186852428b3c2119fc.

</details>

<details>
<summary>📒 Files selected for processing (1)</summary>

* `aws-ses-sender/README.md` (1 hunks)

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🪛 LanguageTool</summary>

<details>
<summary>aws-ses-sender/README.md</summary>

[uncategorized] ~11-~11: If this is a compound adjective that modifies the following noun, use a hyphen.
Context: ...he basic sending functionality from [my open source project](https://github.com/lee-lou2/aw...

(EN_COMPOUND_ADJECTIVE_INTERNAL)

</details>

</details>

</details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T23:27:41Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 0**

<details>
<summary>🧹 Nitpick comments (4)</summary><blockquote>

<details>
<summary>aws-ses-sender/api/handler.go (2)</summary><blockquote>

`154-156`: **Consider returning more specific error details**

The error message when failing to unmarshal the SES notification is generic. Adding the actual error details would aid in debugging potential JSON format issues.

```diff
-		return c.JSON(fiber.Map{"message": "Non-SES notification received"})
+		return c.JSON(fiber.Map{"message": fmt.Sprintf("Non-SES notification received: %v", err)})
```

---

`178-185`: **Log database errors when result creation fails**

The function returns an error response but doesn't log the actual error, which makes debugging production issues more difficult.

```diff
	if err := db.Create(&model.Result{
		RequestId: reqId,
		Status:    sesNotification.NotificationType,
		Raw:       reqBody.Message,
	}).Error; err != nil {
+		log.Printf("Failed to save result event: %v", err)
		return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to save event"})
	}
```

</blockquote></details>
<details>
<summary>aws-ses-sender/README.md (2)</summary><blockquote>

`11-11`: **Consider hyphenating the compound adjective "open-source"**

When "open source" is used as a compound adjective modifying a noun (like "project"), it should be hyphenated as "open-source project".

```diff
-This is an AWS SES-based Golang email delivery service that extracts only the basic sending functionality from [my open source project](https://github.com/lee-lou2/aws-ses-sender-go).
+This is an AWS SES-based Golang email delivery service that extracts only the basic sending functionality from [my open-source project](https://github.com/lee-lou2/aws-ses-sender-go).
```

<details>
<summary>🧰 Tools</summary>

<details>
<summary>🪛 LanguageTool</summary>

[uncategorized] ~11-~11: If this is a compound adjective that modifies the following noun, use a hyphen.
Context: ...he basic sending functionality from [my open source project](https://github.com/lee-lou2/aw...

(EN_COMPOUND_ADJECTIVE_INTERNAL)

</details>

</details>

---

`125-126`: **Add actual repository URL to clone command**

The git clone instructions are incomplete without the actual repository URL, making it difficult for users to follow the setup steps.

```diff
-   git clone <repository_URL>
+   git clone https://github.com/gofiber/recipes.git
+   cd recipes
    cd aws-ses-sender
```

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between d7a3fbb246fb8fc536794e186852428b3c2119fc and 5ceb8d84b5650a7be0b0af08d316592511810874.

</details>

<details>
<summary>📒 Files selected for processing (2)</summary>

* `aws-ses-sender/README.md` (1 hunks)
* `aws-ses-sender/api/handler.go` (1 hunks)

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🪛 LanguageTool</summary>

<details>
<summary>aws-ses-sender/README.md</summary>

[uncategorized] ~11-~11: If this is a compound adjective that modifies the following noun, use a hyphen.
Context: ...he basic sending functionality from [my open source project](https://github.com/lee-lou2/aw...

(EN_COMPOUND_ADJECTIVE_INTERNAL)

</details>

</details>

</details>

<details>
<summary>🔇 Additional comments (5)</summary><blockquote>

<details>
<summary>aws-ses-sender/api/handler.go (4)</summary>

`23-83`: **LGTM! Email request handler implementation looks solid.**

The createMessageHandler properly handles email validation, chunk-based database insertion for performance, and provides useful response metrics. The implementation addresses previous review feedback with RFC3339 time parsing and proper email validation.

---

`87-114`: **LGTM! Open tracking implementation with proper error handling.**

The open event tracking implementation now properly handles errors during request ID conversion and database operations, with appropriate logging. The 1x1 transparent PNG approach for tracking is a standard practice.

---

`189-275`: **LGTM! Efficient implementation of result counting.**

The getResultCountHandler uses efficient database query patterns with proper early exits, optimized subqueries, and comprehensive error handling.

---

`277-300`: **LGTM! Well-implemented sent count handler with configurable time window.**

The getSentCountHandler properly handles the optional hours parameter with a sensible default and uses an efficient database query.

</details>
<details>
<summary>aws-ses-sender/README.md (1)</summary>

`141-179`: **LGTM! Clear API documentation with examples.**

The API endpoints section provides comprehensive documentation with clear endpoint definitions and request examples, which will be very helpful for users.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T23:33:46Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 4**

<details>
<summary>🧹 Nitpick comments (3)</summary><blockquote>

<details>
<summary>aws-ses-sender/api/handler.go (3)</summary><blockquote>

`23-83`: **Memory efficiency could be improved for bulk processing.**

The current implementation builds a full array of all requests in memory before inserting them into the database. For very large message batches, this could lead to high memory usage.


Consider processing each message group separately instead of accumulating all requests before database insertion:

```diff
- reqs := make([]*model.Request, 0)
- totCnt := 0
- for _, msg := range reqBody.Messages {
-   // Process message and add to reqs
- }
- 
- chunkSize := 1000
- for i := 0; i < len(reqs); i += chunkSize {
-   end := i + chunkSize
-   if end > len(reqs) {
-     end = len(reqs)
-   }
-   if err := db.Create(reqs[i:end]).Error; err != nil {
-     return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
-   }
- }

+ totCnt := 0
+ chunkSize := 1000
+ for _, msg := range reqBody.Messages {
+   // Process message  
+   
+   // Create chunk array for current message only
+   msgReqs := make([]*model.Request, 0, len(msg.Emails))
+   
+   // Process all emails in message
+   for _, email := range msg.Emails {
+     // Email validation...
+     req := &model.Request{/*...*/}
+     msgReqs = append(msgReqs, req)
+     totCnt++
+   }
+   
+   // Insert chunk by chunk for current message
+   for i := 0; i < len(msgReqs); i += chunkSize {
+     end := i + chunkSize
+     if end > len(msgReqs) {
+       end = len(msgReqs)
+     }
+     if err := db.Create(msgReqs[i:end]).Error; err != nil {
+       return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+     }
+   }
+ }
```

---

`278-302`: **Document and review the 168-hour limit.**

The code imposes a 168-hour (7-day) limit for the time range query without explanation. This arbitrary limit might restrict valid use cases.


Add comments explaining the rationale and consider if this limit is appropriate:

```diff
 // getSentCountHandler Retrieve the number of emails sent within 24 hours
 func getSentCountHandler(c fiber.Ctx) error {
-  // Receive hours as a query string (default 24)
+  // Receive hours as a query string (default 24)
+  // We limit to 168 hours (7 days) to prevent excessive database load from large queries
+  // This limit should be reviewed periodically based on usage patterns and performance metrics
   hours, err := strconv.Atoi(c.Query("hours", "24"))
   if err != nil || hours <= 0 || hours > 168 {
     log.Printf("Invalid hours: %v", err)
     return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "hours must be 1-168"})
   }
```

Also consider implementing caching for frequently accessed time ranges:

```diff
+  // Check cache for common time ranges to reduce database load
+  cacheKey := fmt.Sprintf("sent_count:%d", hours)
+  if cachedCount, found := getFromCache(cacheKey); found {
+    return c.JSON(fiber.Map{"count": cachedCount})
+  }

   // Calculate the time hours before the current time
   startTime := time.Now().Add(-time.Duration(hours) * time.Hour)
   
   // Get the number of emails after startTime from the DB
   // ...existing code...
   
+  // Cache the result for a short period (e.g., 5 minutes)
+  setInCache(cacheKey, cnt, 5*time.Minute)
   
   // Return the result
   return c.JSON(fiber.Map{"count": cnt})
```

---

`190-276`: **Implement pagination for result counts.**

The `getResultCountHandler` could return large datasets for popular topics, but doesn't implement pagination, which could impact performance.


Consider adding pagination or result limiting for larger topics:

```diff
 func getResultCountHandler(c fiber.Ctx) error {
   topicID := c.Params("topicId")
   if topicID == "" {
     return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "topicId is required"})
   }
+  
+  // Parse pagination parameters
+  limit, _ := strconv.Atoi(c.Query("limit", "1000"))
+  if limit <= 0 || limit > 5000 {
+    limit = 1000  // Default to 1000, cap at 5000
+  }
+  
+  // For detailed results, we might want pagination
+  page, _ := strconv.Atoi(c.Query("page", "1"))
+  if page <= 0 {
+    page = 1
+  }
+  offset := (page - 1) * limit

   db := config.GetDB()
   
   // ... existing count queries ...
   
   // Add limit to subquery for large topics
+  subQuery := db.Model(&model.Request{}).
+    Select("id").
+    Where("topic_id = ?", topicID).
+    Limit(limit).
+    Offset(offset)
-  subQuery := db.Model(&model.Request{}).
-    Select("id").
-    Where("topic_id = ?", topicID)
   
   // ... rest of the function ...
   
   // Add pagination metadata to response
+  return c.JSON(fiber.Map{
+    "request": reqCnts,
+    "result": fiber.Map{
+      "statuses": resultCounts,
+    },
+    "pagination": fiber.Map{
+      "page": page,
+      "limit": limit,
+      "total": reqCnt,
+      "pages": int(math.Ceil(float64(reqCnt) / float64(limit))),
+    },
+  })
```

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 2e42addc859281348cdd869f80ba77590d03cd69 and b32c595d235be6c51c03770895b69c54068f201c.

</details>

<details>
<summary>📒 Files selected for processing (3)</summary>

* `aws-ses-sender/README.md` (1 hunks)
* `aws-ses-sender/api/handler.go` (1 hunks)
* `aws-ses-sender/api/middlewares.go` (1 hunks)

</details>

<details>
<summary>✅ Files skipped from review due to trivial changes (1)</summary>

* aws-ses-sender/README.md

</details>

<details>
<summary>🚧 Files skipped from review as they are similar to previous changes (1)</summary>

* aws-ses-sender/api/middlewares.go

</details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T03:00:09Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 0**

<details>
<summary>♻️ Duplicate comments (1)</summary><blockquote>

<details>
<summary>aws-ses-sender/api/handler.go (1)</summary><blockquote>

`50-55`: _🛠️ Refactor suggestion_

**Silently dropping past-dated schedules hides client errors**

When `scheduledAt` is in the past the handler logs and `continue`s, so the caller receives `200 OK` even though the message set was discarded.  
This makes troubleshooting very hard.



```diff
-    if scheduledAt.Before(time.Now().UTC()) {
-        log.Printf("Scheduled time must be in the future")
-        continue
-    }
+    if scheduledAt.Before(time.Now().UTC()) {
+        return c.Status(fiber.StatusBadRequest).
+            JSON(fiber.Map{"error": "scheduledAt must be in the future"})
+    }
```

</blockquote></details>

</blockquote></details>

<details>
<summary>🧹 Nitpick comments (4)</summary><blockquote>

<details>
<summary>aws-ses-sender/api/handler.go (4)</summary><blockquote>

`61-67`: **Duplicate field validation inside the inner loop**

`Subject` and `Content` are common to all recipients in a message group, yet the checks run once per e-mail, doing unnecessary work and cluttering the hot path.



```diff
-for _, email := range msg.Emails {
-    // Validate email address
-    …
-    // Validate essential message fields
-    if strings.TrimSpace(msg.Subject) == "" { … }
-    if strings.TrimSpace(msg.Content) == "" { … }
+// Validate essential message fields **once** per group
+if strings.TrimSpace(msg.Subject) == "" {
+    return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Subject cannot be empty"})
+}
+if strings.TrimSpace(msg.Content) == "" {
+    return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Content cannot be empty"})
+}
+for _, email := range msg.Emails {
+    // Validate email address
+    …
```

---

`81-90`: **Use GORM `CreateInBatches` for simpler & faster bulk inserts**

Manual chunking works but GORM already provides batch insertion with proper size tuning. This removes boilerplate and lets GORM optimise for the configured dialect.



```diff
-chunkSize := 1000
-for i := 0; i < len(reqs); i += chunkSize {
-    end := i + chunkSize
-    if end > len(reqs) {
-        end = len(reqs)
-    }
-    if err := db.Create(reqs[i:end]).Error; err != nil {
-        return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
-    }
-}
+if err := db.CreateInBatches(reqs, 1000).Error; err != nil {
+    return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+}
```

---

`120-127`: **Avoid re-encoding the same 1×1 PNG for every open event**

PNG encoding allocates and compresses on every hit. A 68-byte transparent PNG can be pre-generated once and served from a `var` to cut latency and GC overhead.



```diff
-var buf bytes.Buffer
-if err := png.Encode(&buf, img); err != nil {
-    return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
-}
-c.Set("Content-Type", "image/png")
-return c.Send(buf.Bytes())
+// preEncodedPNG should be initialised in an `init()` function or package-level var
+c.Set("Content-Type", "image/png")
+return c.Send(preEncodedPNG)
```

---

`133-136`: **Header lookup should ignore case**

HTTP headers are case-insensitive. A downstream load-balancer/SNS may send `X-Amz-Sns-Message-Type`. Comparing with a literal string risks false negatives.



```diff
-msgType := c.Get("x-amz-sns-message-type")
-if msgType != "Notification" && msgType != "SubscriptionConfirmation" {
+msgType := c.Get("x-amz-sns-message-type")
+if !strings.EqualFold(msgType, "Notification") &&
+   !strings.EqualFold(msgType, "SubscriptionConfirmation") {
```

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between b32c595d235be6c51c03770895b69c54068f201c and d010ef36eb960af5d68a34f77269377e1c772b58.

</details>

<details>
<summary>📒 Files selected for processing (1)</summary>

* `aws-ses-sender/api/handler.go` (1 hunks)

</details>

<details>
<summary>🔇 Additional comments (1)</summary><blockquote>

<details>
<summary>aws-ses-sender/api/handler.go (1)</summary>

`34-36`: <details>
<summary>❓ Verification inconclusive</summary>

**Verify Fiber v3 binding call – `c.Bind().JSON` may not exist**

Fiber v3’s (current master) helper is still `ctx.BodyParser(&dst)`; `ctx.Bind()` is discussed but not yet released in a tagged version.  
If the project vendors an older/newer snapshot the current code will not compile.





If the symbol is missing, switch to:

```diff
-if err := c.Bind().JSON(&reqBody); err != nil {
+if err := c.BodyParser(&reqBody); err != nil {
```

---

🏁 Script executed:

```shell
#!/bin/bash
# Quick sanity-check: look for Bind() in the fiber/v3 module we vendored.
grep -R "func (.*) Bind(" "$(go env GOPATH)"/pkg/mod | head
```

Length of output: 237

---



</details>

**Verify Fiber v3’s Bind().JSON API**

I couldn’t locate `ctx.Bind().JSON()` in the vendored Fiber v3 module. Please confirm your Fiber version supports `ctx.Bind()`, and if it doesn’t, switch to using `ctx.BodyParser()`.

• File: `aws-ses-sender/api/handler.go`, lines 34–36  

```diff
-if err := c.Bind().JSON(&reqBody); err != nil {
+if err := c.BodyParser(&reqBody); err != nil {
```

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T03:14:22Z

Review comments:
diff_hunk: @@ -0,0 +1,13 @@
+FROM golang:1.23
+
+WORKDIR /usr/src/app
+
+COPY go.mod go.sum ./
+RUN go mod download && go mod verify
+
+COPY . .
+RUN go build .
+
path: aws-ses-sender/Dockerfile
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Consider using multi-stage builds for a smaller image.**

The current Dockerfile produces a large image that includes the Go toolchain and build artifacts. A multi-stage build would result in a much smaller final image.

```diff
-FROM golang:1.23
+FROM golang:1.22 AS builder
 
 WORKDIR /usr/src/app
 
 COPY go.mod go.sum ./
 RUN go mod download && go mod verify
 
 COPY . .
-RUN go build .
+RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app .
+
+FROM alpine:latest
+RUN apk --no-cache add ca-certificates
+WORKDIR /root/
+COPY --from=builder /usr/src/app/app .
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
FROM golang:1.22 AS builder

WORKDIR /usr/src/app

COPY go.mod go.sum ./
RUN go mod download && go mod verify

COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app .

FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /usr/src/app/app .
`````

</details>

<!-- suggestion_end -->

<details>
<summary>🤖 Prompt for AI Agents (early access)</summary>

```
In aws-ses-sender/Dockerfile around lines 5 to 10, the Dockerfile currently builds the Go application in a single stage, resulting in a large image containing the Go toolchain and build files. Refactor the Dockerfile to use multi-stage builds by first creating a builder stage that compiles the Go binary, then copying only the compiled binary into a minimal base image in the final stage. This will significantly reduce the final image size by excluding unnecessary build dependencies.
```

</details>

<!-- This is an auto-generated reply by CodeRabbit -->

✅ Addressed in commit 3ac0d4a
created_at: 2025-05-07T04:55:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,39 @@
+package api
+
+import (
+	"aws-ses-sender-go/config"
+	"fmt"
+	"log"
+
+	"github.com/gofiber/fiber/v3/middleware/pprof"
+
+	"github.com/gofiber/fiber/v3"
+	"github.com/gofiber/fiber/v3/middleware/logger"
+	recoverer "github.com/gofiber/fiber/v3/middleware/recover"
+	jsoniter "github.com/json-iterator/go"
+)
+
+func Run() {
+	app := fiber.New(
+		fiber.Config{
+			AppName: "aws-ses-sender-go",
+			// JSON Encoder and Decoder(Fast JSON library)
+			JSONEncoder: jsoniter.Marshal,
+			JSONDecoder: jsoniter.Unmarshal,
+		},
+	)
+
+	// Middleware
+	app.Use(pprof.New())
+	app.Use(recoverer.New())
+	app.Use(logger.New(logger.Config{
+		Format:     "${time} ${pid} ${status} - ${method} ${path} ${latency}\n",
+		TimeFormat: "2006-01-02 15:04:05",
+		TimeZone:   "Local",
+	}))
+
+	// Routes
+	setV1Routes(app)
+
+	log.Fatal(app.Listen(fmt.Sprintf(":%s", config.GetEnv("SERVER_PORT", "3000"))))
path: aws-ses-sender/api/server.go
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Improve error handling and implement graceful shutdown.**

Using `log.Fatal` prevents any cleanup operations or detailed error reporting. Additionally, there's no graceful shutdown mechanism to handle ongoing requests when the server is stopped.

```diff
-log.Fatal(app.Listen(fmt.Sprintf(":%s", config.GetEnv("SERVER_PORT", "3000"))))
+port := fmt.Sprintf(":%s", config.GetEnv("SERVER_PORT", "3000"))
+
+// Start the server in a goroutine
+go func() {
+  if err := app.Listen(port); err != nil {
+    log.Printf("Server error: %v", err)
+  }
+}()
+
+// Create channel to listen for shutdown signals
+quit := make(chan os.Signal, 1)
+signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
+
+// Block until signal is received
+<-quit
+log.Println("Shutting down server...")
+
+// Gracefully shutdown with a timeout
+ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+defer cancel()
+if err := app.ShutdownWithContext(ctx); err != nil {
+  log.Fatal("Server forced to shutdown:", err)
+}
+
+log.Println("Server exiting")
```

Don't forget to add the necessary imports:

```diff
 import (
 	"aws-ses-sender-go/config"
+	"context"
 	"fmt"
 	"log"
+	"os"
+	"os/signal"
+	"syscall"
+	"time"
 
 	"github.com/gofiber/fiber/v3/middleware/pprof"
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
// ── Add these imports ───────────────────────────────────────────────────────────
import (
    "aws-ses-sender-go/config"
    "context"
    "fmt"
    "log"
    "os"
    "os/signal"
    "syscall"
    "time"

    "github.com/gofiber/fiber/v3/middleware/pprof"
)

// ── Replace the single log.Fatal call with graceful shutdown ─────────────────

func main() {
    // … any setup code above …

    port := fmt.Sprintf(":%s", config.GetEnv("SERVER_PORT", "3000"))

    // Start the server in a goroutine
    go func() {
        if err := app.Listen(port); err != nil {
            log.Printf("Server error: %v", err)
        }
    }()

    // Create channel to listen for shutdown signals
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, os.Interrupt, syscall.SIGTERM)

    // Block until a signal is received
    <-quit
    log.Println("Shutting down server...")

    // Gracefully shutdown with a timeout
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()
    if err := app.ShutdownWithContext(ctx); err != nil {
        log.Fatal("Server forced to shutdown:", err)
    }

    log.Println("Server exiting")
}
`````

</details>

<!-- suggestion_end -->

<details>
<summary>🤖 Prompt for AI Agents (early access)</summary>

```
In aws-ses-sender/api/server.go at line 38, replace the use of log.Fatal with proper error handling that logs the error without exiting immediately. Implement a graceful shutdown mechanism to allow the server to finish ongoing requests before stopping, using context cancellation and signal handling. Also, add the necessary imports such as "os", "os/signal", "context", and "syscall" to support this functionality.
```

</details>

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-05-07T04:55:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,25 @@
+package main
+
+import (
+	"aws-ses-sender-go/api"
+	"aws-ses-sender-go/cmd"
+	"aws-ses-sender-go/config"
+
+	"github.com/getsentry/sentry-go"
+)
+
+func main() {
+	// Sentry
+	_ = sentry.Init(sentry.ClientOptions{
+		Dsn: config.GetEnv("SENTRY_DSN"),
+	})
path: aws-ses-sender/main.go
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Error from Sentry initialization is ignored**

The Sentry initialization error is discarded with `_`, which means you won't know if it fails to connect. This could lead to missing error reports in production without any indication.

```diff
-	_ = sentry.Init(sentry.ClientOptions{
+	if err := sentry.Init(sentry.ClientOptions{
		Dsn: config.GetEnv("SENTRY_DSN"),
-	})
+	}); err != nil {
+		log.Printf("Sentry initialization failed: %v", err)
+	}
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
-	_ = sentry.Init(sentry.ClientOptions{
-		Dsn: config.GetEnv("SENTRY_DSN"),
-	})
+	if err := sentry.Init(sentry.ClientOptions{
+		Dsn: config.GetEnv("SENTRY_DSN"),
+	}); err != nil {
+		log.Printf("Sentry initialization failed: %v", err)
+	}
`````

</details>

<!-- suggestion_end -->

<details>
<summary>🤖 Prompt for AI Agents (early access)</summary>

```
In aws-ses-sender/main.go around lines 13 to 15, the error returned from sentry.Init is ignored using `_`, which hides any initialization failures. Modify the code to capture the error returned by sentry.Init, check if it is non-nil, and handle it appropriately by logging or returning the error so that initialization issues are visible and can be addressed.
```

</details>

<!-- This is an auto-generated comment by CodeRabbit -->

✅ Addressed in commits 3f51014 to 6c2bdcc
created_at: 2025-05-07T04:55:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,25 @@
+package main
+
+import (
+	"aws-ses-sender-go/api"
+	"aws-ses-sender-go/cmd"
+	"aws-ses-sender-go/config"
path: aws-ses-sender/main.go
user: login: coderabbitai[bot]
body: _⚠️ Potential issue_

**Import path doesn't match directory structure**

The import statements use `aws-ses-sender-go` but the directory is named `aws-ses-sender`. This mismatch will cause build failures unless there's a module configuration that maps this path.

```diff
-	"aws-ses-sender-go/api"
-	"aws-ses-sender-go/cmd"
-	"aws-ses-sender-go/config"
+	"aws-ses-sender/api"
+	"aws-ses-sender/cmd"
+	"aws-ses-sender/config"
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
    "aws-ses-sender/api"
    "aws-ses-sender/cmd"
    "aws-ses-sender/config"
`````

</details>

<!-- suggestion_end -->

<details>
<summary>🤖 Prompt for AI Agents (early access)</summary>

```
In aws-ses-sender/main.go around lines 4 to 6, the import paths use "aws-ses-sender-go" which does not match the actual directory name "aws-ses-sender". Update all import statements to use "aws-ses-sender" instead of "aws-ses-sender-go" to align with the directory structure and avoid build errors.
```

</details>

<!-- This is an auto-generated comment by CodeRabbit -->

✅ Addressed in commit d07017e
created_at: 2025-05-07T04:55:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,25 @@
+package main
+
+import (
+	"aws-ses-sender-go/api"
+	"aws-ses-sender-go/cmd"
+	"aws-ses-sender-go/config"
+
+	"github.com/getsentry/sentry-go"
+)
+
+func main() {
+	// Sentry
+	_ = sentry.Init(sentry.ClientOptions{
+		Dsn: config.GetEnv("SENTRY_DSN"),
+	})
+
+	// Message Scheduler
+	go cmd.RunScheduler()
+
+	// Email Sender
+	go cmd.RunSender()
+
+	// HTTP Server
+	api.Run()
path: aws-ses-sender/main.go
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Add graceful shutdown handling**

The application starts goroutines and the HTTP server, but lacks graceful shutdown handling. When the server receives a termination signal, the goroutines may continue running or terminate abruptly.

```diff
+import (
+	"context"
+	"os"
+	"os/signal"
+	"syscall"
+	"time"
+)

func main() {
	// Sentry
	if err := sentry.Init(sentry.ClientOptions{
		Dsn: config.GetEnv("SENTRY_DSN"),
	}); err != nil {
		log.Printf("Sentry initialization failed: %v", err)
	}
+	// Ensure flush before exit
+	defer sentry.Flush(2 * time.Second)
+
+	// Create a context that will be canceled on interrupt
+	ctx, cancel := context.WithCancel(context.Background())
+	defer cancel()
+
+	// Set up a channel to listen for interrupt signals
+	c := make(chan os.Signal, 1)
+	signal.Notify(c, os.Interrupt, syscall.SIGTERM)
+	go func() {
+		<-c
+		cancel()
+	}()

	// Message Scheduler
-	go cmd.RunScheduler()
+	go cmd.RunScheduler(ctx)

	// Email Sender
-	go cmd.RunSender()
+	go cmd.RunSender(ctx)

	// HTTP Server
-	api.Run()
+	api.Run(ctx)
}
```


> Committable suggestion skipped: line range outside the PR's diff.

<details>
<summary>🤖 Prompt for AI Agents (early access)</summary>

```
In aws-ses-sender/main.go around lines 17 to 24, the code starts goroutines and runs the HTTP server without handling graceful shutdown. To fix this, implement signal capturing for termination signals (like SIGINT, SIGTERM), then coordinate shutdown by signaling the goroutines to stop and gracefully shutting down the HTTP server. Use context cancellation or channels to notify goroutines and ensure all resources are cleaned up before exiting the application.
```

</details>

<!-- This is an auto-generated comment by CodeRabbit -->

✅ Addressed in commits 3f51014 to 6c2bdcc
created_at: 2025-05-07T04:55:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,42 @@
+package config
+
+import (
+	"fmt"
+	"log"
+	"sync"
+
+	"gorm.io/driver/postgres"
+	"gorm.io/gorm"
+	"gorm.io/gorm/logger"
+)
+
+var (
+	dbInstance *gorm.DB
+	dbOnce     sync.Once
+)
+
+// GetDB Database instance
+func GetDB() *gorm.DB {
+	dbOnce.Do(func() {
+		host := GetEnv("DB_HOST", "localhost")
+		port := GetEnv("DB_PORT", "5432")
+		user := GetEnv("DB_USER", "postgres")
+		password := GetEnv("DB_PASSWORD", "postgres")
+		dbname := GetEnv("DB_NAME", "postgres")
+		dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=disable TimeZone=Asia/Seoul",
+			host, user, password, dbname, port)
+		db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
+			Logger: logger.Default.LogMode(logger.Error),
+		})
+		if err != nil {
+			log.Fatalf("Failed to connect to database: %v", err)
+		}
+		sqlDB, err := db.DB()
+		if err != nil {
+			log.Fatalf("Failed to get database instance: %v", err)
+		}
+		sqlDB.SetMaxOpenConns(10)
+		dbInstance = db
+	})
+	return dbInstance
+}
path: aws-ses-sender/config/db.go
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Add method to close database connection during shutdown**

There's no mechanism to gracefully close the database connection when the application shuts down. This might lead to connection leaks or incomplete transactions.

```diff
+// CloseDB closes the database connection
+func CloseDB() error {
+	if dbInstance == nil {
+		return nil
+	}
+	sqlDB, err := dbInstance.DB()
+	if err != nil {
+		return fmt.Errorf("failed to get database instance: %v", err)
+	}
+	return sqlDB.Close()
+}
```

Then update main.go to use this during graceful shutdown:
```go
// In main.go, before exit
defer func() {
    if err := config.CloseDB(); err != nil {
        log.Printf("Error closing database connection: %v", err)
    }
}()
```

<details>
<summary>🤖 Prompt for AI Agents (early access)</summary>

```
In aws-ses-sender/config/db.go around lines 19 to 42, add a new function CloseDB that retrieves the underlying sql.DB from the gorm.DB instance and calls its Close method to properly close the database connection. This function should return any error encountered during closing. Then, update main.go to defer a call to this CloseDB function during application shutdown to ensure the database connection is gracefully closed and prevent connection leaks.
```

</details>

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-05-07T04:55:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,42 @@
+package config
+
+import (
+	"fmt"
+	"log"
+	"sync"
+
+	"gorm.io/driver/postgres"
+	"gorm.io/gorm"
+	"gorm.io/gorm/logger"
+)
+
+var (
+	dbInstance *gorm.DB
+	dbOnce     sync.Once
+)
+
+// GetDB Database instance
+func GetDB() *gorm.DB {
+	dbOnce.Do(func() {
+		host := GetEnv("DB_HOST", "localhost")
+		port := GetEnv("DB_PORT", "5432")
+		user := GetEnv("DB_USER", "postgres")
+		password := GetEnv("DB_PASSWORD", "postgres")
+		dbname := GetEnv("DB_NAME", "postgres")
+		dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=disable TimeZone=Asia/Seoul",
+			host, user, password, dbname, port)
path: aws-ses-sender/config/db.go
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Hardcoded timezone in database connection string**

The database connection string has a hardcoded timezone set to "Asia/Seoul". This might not be appropriate for all deployments and could cause date/time issues in different regions.

```diff
-		dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=disable TimeZone=Asia/Seoul",
+		timezone := GetEnv("DB_TIMEZONE", "UTC")
+		dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=disable TimeZone=%s",
-			host, user, password, dbname, port)
+			host, user, password, dbname, port, timezone)
```


> Committable suggestion skipped: line range outside the PR's diff.

<details>
<summary>🤖 Prompt for AI Agents (early access)</summary>

```
In aws-ses-sender/config/db.go around lines 26 to 27, the database connection string has a hardcoded timezone "Asia/Seoul" which may cause issues in different deployment regions. Modify the code to accept the timezone as a configurable parameter instead of hardcoding it. Update the connection string to use this parameter so the timezone can be set dynamically based on deployment environment.
```

</details>

<!-- This is an auto-generated comment by CodeRabbit -->

✅ Addressed in commits 3f51014 to 6c2bdcc
created_at: 2025-05-07T04:55:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,17 @@
+package api
+
+import (
+	"github.com/gofiber/fiber/v3"
+)
+
+// setV1Routes V1 Routes
+func setV1Routes(app *fiber.App) {
+	// Messages
+	app.Post("/v1/messages", createMessageHandler)
+	// Topics
+	app.Get("/v1/topics/:topicId", getResultCountHandler)
+	// Events
+	app.Get("/v1/events/open", createOpenEventHandler)
+	app.Get("/v1/events/counts/sent", getSentCountHandler)
+	app.Post("/v1/events/results", createResultEventHandler)
+}
path: aws-ses-sender/api/route.go
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Consider adding middleware for API security**

There's no security middleware applied to these routes. Depending on your deployment model, you might want to add authentication, rate limiting, and possibly CORS protection.

```diff
 // setV1Routes V1 Routes
 func setV1Routes(app *fiber.App) {
+	// API security middleware
+	v1 := app.Group("/v1")
+	
+	// Apply security middleware if not in development mode
+	if config.GetEnv("ENV", "development") != "development" {
+		// Add authentication middleware for protected endpoints
+		authMiddleware := func(c fiber.Ctx) error {
+			token := c.Get("Authorization")
+			if token == "" || token != "Bearer "+config.GetEnv("API_KEY") {
+				return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
+					"error": "Unauthorized",
+				})
+			}
+			return c.Next()
+		}
+		
+		// Messages and Topics require authentication
+		v1.Use("/messages", authMiddleware)
+		v1.Use("/topics", authMiddleware)
+	}
+	
 	// Messages
-	app.Post("/v1/messages", createMessageHandler)
+	v1.Post("/messages", createMessageHandler)
 	// Topics
-	app.Get("/v1/topics/:topicId", getResultCountHandler)
+	v1.Get("/topics/:topicId", getResultCountHandler)
 	// Events
-	app.Get("/v1/events/open", createOpenEventHandler)
-	app.Get("/v1/events/counts/sent", getSentCountHandler)
-	app.Post("/v1/events/results", createResultEventHandler)
+	v1.Get("/events/open", createOpenEventHandler)
+	v1.Get("/events/counts/sent", getSentCountHandler)
+	v1.Post("/events/results", createResultEventHandler)
 }
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
// setV1Routes V1 Routes
func setV1Routes(app *fiber.App) {
	// API security middleware
	v1 := app.Group("/v1")

	// Apply security middleware if not in development mode
	if config.GetEnv("ENV", "development") != "development" {
		// Add authentication middleware for protected endpoints
		authMiddleware := func(c fiber.Ctx) error {
			token := c.Get("Authorization")
			if token == "" || token != "Bearer "+config.GetEnv("API_KEY") {
				return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
					"error": "Unauthorized",
				})
			}
			return c.Next()
		}

		// Messages and Topics require authentication
		v1.Use("/messages", authMiddleware)
		v1.Use("/topics", authMiddleware)
	}

	// Messages
	v1.Post("/messages", createMessageHandler)
	// Topics
	v1.Get("/topics/:topicId", getResultCountHandler)
	// Events
	v1.Get("/events/open", createOpenEventHandler)
	v1.Get("/events/counts/sent", getSentCountHandler)
	v1.Post("/events/results", createResultEventHandler)
}
`````

</details>

<!-- suggestion_end -->

<details>
<summary>🤖 Prompt for AI Agents (early access)</summary>

```
In aws-ses-sender/api/route.go around lines 8 to 17, the route handlers lack security middleware. To fix this, add appropriate middleware for authentication, rate limiting, and CORS protection to the routes defined in setV1Routes. Integrate these middleware functions with the Fiber app instance before or during route registration to ensure API security.
```

</details>

<!-- This is an auto-generated comment by CodeRabbit -->

✅ Addressed in commits 3f51014 to 6c2bdcc
created_at: 2025-05-07T04:55:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,71 @@
+package aws
+
+import (
+	"aws-ses-sender-go/config"
+	"context"
+	"strconv"
+
+	"github.com/aws/aws-sdk-go-v2/aws"
+	awsConfig "github.com/aws/aws-sdk-go-v2/config"
+	"github.com/aws/aws-sdk-go-v2/credentials"
+	"github.com/aws/aws-sdk-go-v2/service/sesv2"
+	"github.com/aws/aws-sdk-go-v2/service/sesv2/types"
+)
+
+// SES is a wrapper around the AWS SES client
+type SES struct {
+	Client *sesv2.Client
+}
+
+// NewSESClient creates an email client
+func NewSESClient(ctx context.Context) (*SES, error) {
+	AccessKeyId := config.GetEnv("AWS_ACCESS_KEY_ID")
+	SecretAccessKey := config.GetEnv("AWS_SECRET_ACCESS_KEY")
+	cfg, err := awsConfig.LoadDefaultConfig(
+		ctx,
+		awsConfig.WithRegion("ap-northeast-2"),
+		awsConfig.WithCredentialsProvider(
+			credentials.NewStaticCredentialsProvider(AccessKeyId, SecretAccessKey, ""),
+		),
path: aws-ses-sender/pkg/aws/ses.go
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Rely on the default AWS credential chain when explicit keys are absent**  
If `AWS_ACCESS_KEY_ID/SECRET_ACCESS_KEY` are empty you still create a static credentials provider with empty strings, short-circuiting IAM roles and other default providers (e.g. EC2/ECS instance profiles, `~/.aws/credentials`).  

Guard against that, or better: omit the custom provider when the explicit keys are not set.

<details>
<summary>🤖 Prompt for AI Agents (early access)</summary>

```
In aws-ses-sender/pkg/aws/ses.go around lines 22 to 29, the code always creates a static credentials provider even when AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY are empty, which bypasses the default AWS credential chain. Modify the code to check if both keys are non-empty before adding the static credentials provider; if either is empty, omit the custom provider so the AWS SDK falls back to the default credential chain including IAM roles and shared config files.
```

</details>

<!-- This is an auto-generated comment by CodeRabbit -->

✅ Addressed in commits 3f51014 to 6c2bdcc
created_at: 2025-05-07T04:55:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,71 @@
+package aws
+
+import (
+	"aws-ses-sender-go/config"
+	"context"
+	"strconv"
+
+	"github.com/aws/aws-sdk-go-v2/aws"
+	awsConfig "github.com/aws/aws-sdk-go-v2/config"
+	"github.com/aws/aws-sdk-go-v2/credentials"
+	"github.com/aws/aws-sdk-go-v2/service/sesv2"
+	"github.com/aws/aws-sdk-go-v2/service/sesv2/types"
+)
+
+// SES is a wrapper around the AWS SES client
+type SES struct {
+	Client *sesv2.Client
+}
+
+// NewSESClient creates an email client
+func NewSESClient(ctx context.Context) (*SES, error) {
+	AccessKeyId := config.GetEnv("AWS_ACCESS_KEY_ID")
+	SecretAccessKey := config.GetEnv("AWS_SECRET_ACCESS_KEY")
+	cfg, err := awsConfig.LoadDefaultConfig(
+		ctx,
+		awsConfig.WithRegion("ap-northeast-2"),
+		awsConfig.WithCredentialsProvider(
+			credentials.NewStaticCredentialsProvider(AccessKeyId, SecretAccessKey, ""),
+		),
+	)
+	if err != nil {
+		return nil, err
+	}
+	return &SES{
+		Client: sesv2.NewFromConfig(cfg),
+	}, nil
+}
+
+// SendEmail sends an email
+func (s *SES) SendEmail(ctx context.Context, reqId int, subject, body *string, receivers *[]string) (string, error) {
+	sender := config.GetEnv("EMAIL_SENDER")
+	input := &sesv2.SendEmailInput{
+		FromEmailAddress: aws.String(sender),
+		Destination: &types.Destination{
+			ToAddresses: *receivers,
+		},
path: aws-ses-sender/pkg/aws/ses.go
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**API ergonomics & minor Go-idioms**

1. `receivers *[]string` is awkward—none of the AWS SDK methods require a pointer to a slice.  
2. The `Destination.ToAddresses` field expects a slice, so dereferencing is unnecessary.  
3. Returning `*result.MessageId` without a nil-check can panic on rare error paths.

```diff
-func (s *SES) SendEmail(ctx context.Context, reqId int, subject, body *string, receivers *[]string) (string, error) {
+func (s *SES) SendEmail(ctx context.Context, reqID int, subject, body *string, receivers []string) (string, error) {
 ...
-        ToAddresses: *receivers,
+        ToAddresses: receivers,
 ...
-            Value: aws.String(strconv.Itoa(reqId)),
+            Value: aws.String(strconv.Itoa(reqID)),
 ...
-    return *result.MessageId, nil
+    if result.MessageId == nil {
+        return "", fmt.Errorf("SES returned nil MessageId")
+    }
+    return *result.MessageId, nil
```



Also applies to: 57-63

<details>
<summary>🤖 Prompt for AI Agents (early access)</summary>

```
In aws-ses-sender/pkg/aws/ses.go around lines 40 to 46 and 57 to 63, change the receivers parameter from a pointer to a slice ( *[]string ) to a plain slice ( []string ) since AWS SDK methods do not require pointers to slices. Remove the dereferencing of receivers when assigning to Destination.ToAddresses, passing the slice directly. Add a nil-check before returning *result.MessageId to avoid potential panics if MessageId is nil, returning an appropriate error or empty string instead.
```

</details>

<!-- This is an auto-generated comment by CodeRabbit -->

✅ Addressed in commits 3f51014 to 6c2bdcc
created_at: 2025-05-07T04:55:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,71 @@
+package aws
+
+import (
+	"aws-ses-sender-go/config"
+	"context"
+	"strconv"
+
+	"github.com/aws/aws-sdk-go-v2/aws"
+	awsConfig "github.com/aws/aws-sdk-go-v2/config"
+	"github.com/aws/aws-sdk-go-v2/credentials"
+	"github.com/aws/aws-sdk-go-v2/service/sesv2"
+	"github.com/aws/aws-sdk-go-v2/service/sesv2/types"
+)
+
+// SES is a wrapper around the AWS SES client
+type SES struct {
+	Client *sesv2.Client
+}
+
+// NewSESClient creates an email client
+func NewSESClient(ctx context.Context) (*SES, error) {
+	AccessKeyId := config.GetEnv("AWS_ACCESS_KEY_ID")
+	SecretAccessKey := config.GetEnv("AWS_SECRET_ACCESS_KEY")
+	cfg, err := awsConfig.LoadDefaultConfig(
+		ctx,
+		awsConfig.WithRegion("ap-northeast-2"),
+		awsConfig.WithCredentialsProvider(
+			credentials.NewStaticCredentialsProvider(AccessKeyId, SecretAccessKey, ""),
+		),
+	)
path: aws-ses-sender/pkg/aws/ses.go
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Make the AWS region configurable instead of hard-coding it**  
`awsConfig.WithRegion("ap-northeast-2")` forces everyone to run in a single region and ignores the `AWS_REGION` variable you already document in the README. Falling back to an env var keeps local dev, CI and production flexible.

```diff
-		awsConfig.WithRegion("ap-northeast-2"),
+		awsConfig.WithRegion(config.GetEnv("AWS_REGION", "ap-northeast-2")),
```

<details>
<summary>🤖 Prompt for AI Agents (early access)</summary>

```
In aws-ses-sender/pkg/aws/ses.go around lines 24 to 30, the AWS region is hard-coded to "ap-northeast-2". Modify the code to read the region from the environment variable AWS_REGION instead, falling back to a default if the env var is not set. Replace the fixed string in awsConfig.WithRegion with this dynamic value to make the region configurable for different environments.
```

</details>

<!-- This is an auto-generated comment by CodeRabbit -->

✅ Addressed in commits 3f51014 to 6c2bdcc
created_at: 2025-05-07T04:55:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,58 @@
+package cmd
+
+import (
+	"aws-ses-sender-go/config"
+	"aws-ses-sender-go/model"
+	"aws-ses-sender-go/pkg/aws"
+	"context"
+	"strconv"
+	"time"
+)
+
+var reqChan = make(chan *model.Request, 1000)
+
+// RunSender runs the email sender
+// It consumes the email sending requests from the channel and sends them to the AWS SES
+func RunSender() {
+	rateStr := config.GetEnv("EMAIL_RATE", "14")
+	rate, _ := strconv.Atoi(rateStr)
path: aws-ses-sender/cmd/sender.go
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Handle invalid or zero `EMAIL_RATE` to avoid division-by-zero panics**  
`strconv.Atoi` can fail and `rate` can legitimately be `0`, causing `time.Second / 0` to panic. Validate the conversion and range:

```diff
-	rateStr := config.GetEnv("EMAIL_RATE", "14")
-	rate, _ := strconv.Atoi(rateStr)
+	rateStr := config.GetEnv("EMAIL_RATE", "14")
+	rate, errConv := strconv.Atoi(rateStr)
+	if errConv != nil || rate <= 0 {
+		log.Fatalf("invalid EMAIL_RATE: %s", rateStr)
+	}
```



Also applies to: 26-26

<details>
<summary>🤖 Prompt for AI Agents (early access)</summary>

```
In aws-ses-sender/cmd/sender.go around lines 17 to 18 and line 26, the code converts the EMAIL_RATE environment variable to an integer without checking for errors or zero values, which can cause a division-by-zero panic. Modify the code to check if strconv.Atoi returns an error or if the rate is zero or less; if so, set a safe default value (e.g., 14). This validation will prevent panics when using rate in time calculations.
```

</details>

<!-- This is an auto-generated comment by CodeRabbit -->

✅ Addressed in commits 3f51014 to 6c2bdcc
created_at: 2025-05-07T04:55:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,56 @@
+package cmd
+
+import (
+	"aws-ses-sender-go/config"
+	"aws-ses-sender-go/model"
+	"log"
+	"strconv"
+	"time"
+)
+
+// RunScheduler runs the scheduler
+// It schedules the email sending requests to be processed by the sender
+func RunScheduler() {
+	db := config.GetDB()
+	sendPerSecStr := config.GetEnv("EMAIL_RATE", "14")
+	sendPerSec, _ := strconv.Atoi(sendPerSecStr)
+	sendPerMin := sendPerSec * 60
+	batchSize := 1000
+
+	ticker := time.NewTicker(1 * time.Minute)
+	for range ticker.C {
+		for i := 0; i < sendPerMin; i += batchSize {
+			now := time.Now()
+			reqs := make([]*model.Request, 0, batchSize)
+			err := db.Raw(`
+				UPDATE email_requests
+				SET status = ?, updated_at = ?
+				WHERE id IN (
+					SELECT id
+					FROM email_requests
+					WHERE status = ? AND (scheduled_at <= ? OR scheduled_at IS NULL) AND deleted_at IS NULL
+					ORDER BY id
+					LIMIT ?
+					FOR UPDATE SKIP LOCKED
+				)
+				RETURNING *;
+			`,
path: aws-ses-sender/cmd/scheduler.go
user: login: coderabbitai[bot]
body: _⚠️ Potential issue_

**PostgreSQL syntax & locking: verify the `UPDATE … IN (…) FOR UPDATE` pattern**  
`FOR UPDATE SKIP LOCKED` inside the sub-query of an `UPDATE … WHERE id IN (…)` is not valid PostgreSQL (the lock clause is ignored). A common pattern is:

```sql
WITH cte AS (
  SELECT id FROM email_requests
  WHERE …
  ORDER BY id
  LIMIT $1
  FOR UPDATE SKIP LOCKED
)
UPDATE email_requests
SET status = $2, updated_at = $3
FROM cte
WHERE email_requests.id = cte.id
RETURNING email_requests.*;
```

Please test the current query; if it silently fails the scheduler will never hand work to the sender.

<details>
<summary>🤖 Prompt for AI Agents (early access)</summary>

```
In aws-ses-sender/cmd/scheduler.go around lines 25 to 37, the current SQL query uses `FOR UPDATE SKIP LOCKED` inside a subquery of an `UPDATE ... WHERE id IN (...)`, which is invalid in PostgreSQL and causes the lock clause to be ignored. To fix this, rewrite the query using a CTE (WITH clause) that selects the rows with `FOR UPDATE SKIP LOCKED`, then update the main table by joining with this CTE. Replace the current query with the recommended pattern using a CTE to ensure proper row locking and correct scheduler behavior.
```

</details>

<!-- This is an auto-generated comment by CodeRabbit -->

✅ Addressed in commits 3f51014 to 6c2bdcc
created_at: 2025-05-07T04:55:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,50 @@
+package model
+
+import (
+	"aws-ses-sender-go/config"
+	"time"
+
+	"gorm.io/gorm"
+)
+
+const (
+	EmailMessageStatusCreated    = iota // Creation complete
+	EmailMessageStatusProcessing        // Processing
+	EmailMessageStatusSent              // Sent
+	EmailMessageStatusFailed            // Failed
+	EmailMessageStatusStopped           // Stopped
+)
+
+type Request struct {
+	gorm.Model
+	TopicId     string     `json:"topic_id" gorm:"index;null"`
+	MessageId   string     `json:"message_id" gorm:"index;null;type:varchar(255)"`
+	To          string     `json:"to" gorm:"not null;type:varchar(255)"`
+	Subject     string     `json:"subject" gorm:"not null;type:varchar(255)"`
+	Content     string     `json:"content" gorm:"not null;type:text"`
+	ScheduledAt *time.Time `json:"scheduled_at" gorm:"null;index;type:timestamp with time zone"`
+	Status      int        `json:"status" gorm:"default:0;index;not null;type:smallint"`
+	Error       string     `json:"error" gorm:"null;type:varchar(255)"`
+}
path: aws-ses-sender/model/email.go
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Incorrect GORM tag option `null` & missing pointer types for nullable columns**

`gorm` does not recognise the option `null`; anything it does not understand is silently ignored, therefore the fields remain `NOT NULL` in the generated schema.  
If you really want database `NULL` support, make the field a pointer or use `sql.Null*` and simply omit the tag; GORM will emit a nullable column automatically for pointer types.

```diff
-TopicId     string     `json:"topic_id" gorm:"index;null"`
-MessageId   string     `json:"message_id" gorm:"index;null;type:varchar(255)"`
-Error       string     `json:"error" gorm:"null;type:varchar(255)"`
+TopicID     *string    `json:"topic_id" gorm:"index"`
+MessageID   *string    `json:"message_id" gorm:"index;type:varchar(255)"`
+Error       *string    `json:"error" gorm:"type:varchar(255)"`
```

(Notice the Go-idiomatic `ID` capitalisation as well.)

Without this change every empty string becomes `''` (non-null) which breaks `IS NULL` queries and foreign-key constraints later in the code.  



<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
type Request struct {
	gorm.Model
	TopicID     *string    `json:"topic_id" gorm:"index"`
	MessageID   *string    `json:"message_id" gorm:"index;type:varchar(255)"`
	To          string     `json:"to" gorm:"not null;type:varchar(255)"`
	Subject     string     `json:"subject" gorm:"not null;type:varchar(255)"`
	Content     string     `json:"content" gorm:"not null;type:text"`
	ScheduledAt *time.Time `json:"scheduled_at" gorm:"null;index;type:timestamp with time zone"`
	Status      int        `json:"status" gorm:"default:0;index;not null;type:smallint"`
	Error       *string    `json:"error" gorm:"type:varchar(255)"`
}
`````

</details>

<!-- suggestion_end -->

<details>
<summary>🤖 Prompt for AI Agents (early access)</summary>

```
In aws-ses-sender/model/email.go around lines 18 to 28, the GORM tag option `null` is incorrect and ignored, causing fields to be non-nullable. To fix this, remove the `null` tag option and change nullable fields like TopicId, ScheduledAt, and Error to pointer types (e.g., *string) so GORM generates nullable columns. Also, rename TopicId to TopicID to follow Go's ID capitalization convention.
```

</details>

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-05-07T04:55:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,286 @@
+package api
+
+import (
+	"aws-ses-sender-go/config"
+	"aws-ses-sender-go/model"
+	"bytes"
+	"encoding/json"
+	"image"
+	"image/color"
+	"image/png"
+	"log"
+	"strconv"
+	"time"
+
+	"github.com/gofiber/fiber/v3"
+)
+
+// createMessageHandler Message Handler
+// Handler that receives email sending requests
+func createMessageHandler(c fiber.Ctx) error {
+	start := time.Now()
+	var reqBody struct {
+		Messages []struct {
+			TopicId     string   `json:"topicId"`
+			Emails      []string `json:"emails"`
+			Subject     string   `json:"subject"`
+			Content     string   `json:"content"`
+			ScheduledAt string   `json:"scheduledAt"`
+		} `json:"messages"`
+	}
+	if err := c.Bind().JSON(&reqBody); err != nil {
+		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
+	}
+
+	db := config.GetDB()
+	reqs := make([]*model.Request, 0)
+	totCnt := 0
+	for _, msg := range reqBody.Messages {
+		var scheduledAt *time.Time
+		if msg.ScheduledAt != "" {
+			if t, err := time.Parse(time.DateTime, msg.ScheduledAt); err != nil {
+				return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid scheduledAt format"})
+			} else {
+				scheduledAt = &t
+			}
+		}
path: aws-ses-sender/api/handler.go
user: login: coderabbitai[bot]
body: _⚠️ Potential issue_

**Scheduled-time parsing too rigid → use RFC 3339**

`time.DateTime` only accepts `2006-01-02 15:04:05`; most clients send RFC 3339 (`2024-05-08T14:00:00Z`). Parsing will silently fail for valid ISO timestamps.

```diff
-    if t, err := time.Parse(time.DateTime, msg.ScheduledAt); err != nil {
+    if t, err := time.Parse(time.RFC3339, msg.ScheduledAt); err != nil {
```

<details>
<summary>🤖 Prompt for AI Agents (early access)</summary>

```
In aws-ses-sender/api/handler.go around lines 40 to 46, the code uses time.DateTime layout to parse ScheduledAt, which only accepts the format "2006-01-02 15:04:05" and will fail for valid RFC 3339 timestamps. Change the parsing layout to time.RFC3339 to correctly parse ISO 8601 timestamps like "2024-05-08T14:00:00Z". This will ensure ScheduledAt is parsed correctly for most clients.
```

</details>

<!-- This is an auto-generated comment by CodeRabbit -->

✅ Addressed in commits 3f51014 to 6c2bdcc
created_at: 2025-05-07T04:55:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,286 @@
+package api
+
+import (
+	"aws-ses-sender-go/config"
+	"aws-ses-sender-go/model"
+	"bytes"
+	"encoding/json"
+	"image"
+	"image/color"
+	"image/png"
+	"log"
+	"strconv"
+	"time"
+
+	"github.com/gofiber/fiber/v3"
+)
+
+// createMessageHandler Message Handler
+// Handler that receives email sending requests
+func createMessageHandler(c fiber.Ctx) error {
+	start := time.Now()
+	var reqBody struct {
+		Messages []struct {
+			TopicId     string   `json:"topicId"`
+			Emails      []string `json:"emails"`
+			Subject     string   `json:"subject"`
+			Content     string   `json:"content"`
+			ScheduledAt string   `json:"scheduledAt"`
+		} `json:"messages"`
+	}
+	if err := c.Bind().JSON(&reqBody); err != nil {
+		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
+	}
+
+	db := config.GetDB()
+	reqs := make([]*model.Request, 0)
+	totCnt := 0
+	for _, msg := range reqBody.Messages {
+		var scheduledAt *time.Time
+		if msg.ScheduledAt != "" {
+			if t, err := time.Parse(time.DateTime, msg.ScheduledAt); err != nil {
+				return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid scheduledAt format"})
+			} else {
+				scheduledAt = &t
+			}
+		}
+		for _, email := range msg.Emails {
+			req := &model.Request{
+				TopicId:     msg.TopicId,
+				To:          email,
+				Subject:     msg.Subject,
+				Content:     msg.Content,
+				ScheduledAt: scheduledAt,
+				Status:      model.EmailMessageStatusCreated,
+			}
+			reqs = append(reqs, req)
+			totCnt++
+		}
+	}
+
+	chunkSize := 1000
+	for i := 0; i < len(reqs); i += chunkSize {
+		end := i + chunkSize
+		if end > len(reqs) {
+			end = len(reqs)
+		}
+		if err := db.Create(reqs[i:end]).Error; err != nil {
+			return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+		}
+	}
+
+	// Return the result
+	return c.JSON(fiber.Map{
+		"count":   len(reqBody.Messages),
+		"elapsed": time.Since(start).String(),
+	})
+}
+
+// createOpenEventHandler Open Event Handler
+// Attach an image script to the email and assume it has been read when the image is accessed
+func createOpenEventHandler(c fiber.Ctx) error {
+	reqId := c.Query("requestId")
+	if reqId != "" {
+		// Consider email as opened and create data
+		db := config.GetDB()
+		reqIdInt, _ := strconv.Atoi(reqId)
+		_ = db.Create(&model.Result{
+			RequestId: uint(reqIdInt),
+			Status:    "Open",
+			Raw:       "{}",
+		}).Error
+	}
+
+	// Return a blank image
+	img := image.NewRGBA(image.Rect(0, 0, 1, 1))
+	img.Set(0, 0, color.RGBA{R: 0, G: 0, B: 0, A: 0})
+	var buf bytes.Buffer
+	if err := png.Encode(&buf, img); err != nil {
+		return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+	}
+	c.Set("Content-Type", "image/png")
+	return c.Send(buf.Bytes())
+}
+
+// createResultEventHandler Result Event Handler
+// Handler that receives AWS SES results
+func createResultEventHandler(c fiber.Ctx) error {
+	msgType := c.Get("x-amz-sns-message-type")
+	if msgType != "Notification" && msgType != "SubscriptionConfirmation" {
+		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid SNS Message Type"})
+	}
+
+	var reqBody struct {
+		Type         string `json:"Type"`
+		Message      string `json:"Message"`
+		MessageId    string `json:"MessageId"`
+		SubscribeURL string `json:"SubscribeURL"`
+	}
+	if err := c.Bind().JSON(&reqBody); err != nil {
+		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Failed to parse SNS message"})
+	}
+
+	if reqBody.Type == "SubscriptionConfirmation" {
+		// Subscription confirmation
+		log.Printf("Subscription confirmation required. Visiting: %s", reqBody.SubscribeURL)
+		return c.JSON(fiber.Map{"message": "Subscription confirmation required"})
+	}
+
+	if reqBody.Type != "Notification" {
+		return c.JSON(fiber.Map{"message": "Other message type received"})
+	}
+
+	var sesNotification struct {
+		NotificationType string `json:"notificationType"`
+		Mail             struct {
+			MessageId string `json:"messageId"`
+			Headers   []struct {
+				Name  string `json:"name"`
+				Value string `json:"value"`
+			} `json:"headers"`
+		} `json:"mail"`
+	}
+	if err := json.Unmarshal([]byte(reqBody.Message), &sesNotification); err != nil {
+		return c.JSON(fiber.Map{"message": "Non-SES notification received"})
+	}
+	if sesNotification.Mail.MessageId == "" {
+		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "SES message_id not found"})
+	}
+
+	// Find request_id from headers
+	var reqId uint
+	for _, header := range sesNotification.Mail.Headers {
+		if header.Name == "X-Request-ID" {
+			reqIdInt, _ := strconv.Atoi(header.Value)
+			reqId = uint(reqIdInt)
+			break
+		}
+	}
path: aws-ses-sender/api/handler.go
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Header lookup should be case-insensitive**

AWS may deliver headers as `X-Request-Id` or `x-request-id`. Normalise the name for robustness.

```diff
for _, header := range sesNotification.Mail.Headers {
-    if header.Name == "X-Request-ID" {
+    if strings.EqualFold(header.Name, "X-Request-ID") {
```

<details>
<summary>🤖 Prompt for AI Agents (early access)</summary>

```
In aws-ses-sender/api/handler.go around lines 150 to 158, the header name lookup for "X-Request-ID" is case-sensitive, which can cause missed matches due to variations like "X-Request-Id" or "x-request-id". Modify the code to normalize the header name to a consistent case (e.g., lowercase) before comparison, ensuring the lookup is case-insensitive and robust against different header casing.
```

</details>

<!-- This is an auto-generated comment by CodeRabbit -->

✅ Addressed in commits 3f51014 to 6c2bdcc
created_at: 2025-05-07T04:55:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,286 @@
+package api
+
+import (
+	"aws-ses-sender-go/config"
+	"aws-ses-sender-go/model"
+	"bytes"
+	"encoding/json"
+	"image"
+	"image/color"
+	"image/png"
+	"log"
+	"strconv"
+	"time"
+
+	"github.com/gofiber/fiber/v3"
+)
+
+// createMessageHandler Message Handler
+// Handler that receives email sending requests
+func createMessageHandler(c fiber.Ctx) error {
+	start := time.Now()
+	var reqBody struct {
+		Messages []struct {
+			TopicId     string   `json:"topicId"`
+			Emails      []string `json:"emails"`
+			Subject     string   `json:"subject"`
+			Content     string   `json:"content"`
+			ScheduledAt string   `json:"scheduledAt"`
+		} `json:"messages"`
+	}
+	if err := c.Bind().JSON(&reqBody); err != nil {
+		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
+	}
+
+	db := config.GetDB()
+	reqs := make([]*model.Request, 0)
+	totCnt := 0
+	for _, msg := range reqBody.Messages {
+		var scheduledAt *time.Time
+		if msg.ScheduledAt != "" {
+			if t, err := time.Parse(time.DateTime, msg.ScheduledAt); err != nil {
+				return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid scheduledAt format"})
+			} else {
+				scheduledAt = &t
+			}
+		}
+		for _, email := range msg.Emails {
+			req := &model.Request{
+				TopicId:     msg.TopicId,
+				To:          email,
+				Subject:     msg.Subject,
+				Content:     msg.Content,
+				ScheduledAt: scheduledAt,
+				Status:      model.EmailMessageStatusCreated,
+			}
+			reqs = append(reqs, req)
+			totCnt++
+		}
+	}
+
+	chunkSize := 1000
+	for i := 0; i < len(reqs); i += chunkSize {
+		end := i + chunkSize
+		if end > len(reqs) {
+			end = len(reqs)
+		}
+		if err := db.Create(reqs[i:end]).Error; err != nil {
+			return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+		}
+	}
+
+	// Return the result
+	return c.JSON(fiber.Map{
+		"count":   len(reqBody.Messages),
+		"elapsed": time.Since(start).String(),
+	})
+}
+
+// createOpenEventHandler Open Event Handler
+// Attach an image script to the email and assume it has been read when the image is accessed
+func createOpenEventHandler(c fiber.Ctx) error {
+	reqId := c.Query("requestId")
+	if reqId != "" {
+		// Consider email as opened and create data
+		db := config.GetDB()
+		reqIdInt, _ := strconv.Atoi(reqId)
+		_ = db.Create(&model.Result{
+			RequestId: uint(reqIdInt),
+			Status:    "Open",
+			Raw:       "{}",
+		}).Error
+	}
path: aws-ses-sender/api/handler.go
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Unchecked `strconv.Atoi` may create orphan results**

If `requestId` is non-numeric the conversion returns an error and `reqIdInt` becomes 0, resulting in a `request_id = 0` record. Validate the conversion first.

```diff
-reqIdInt, _ := strconv.Atoi(reqId)
-if reqIdInt > 0 {
-    _ = db.Create(&model.Result{ ... }).Error
-}
+reqIdInt, err := strconv.Atoi(reqId)
+if err == nil && reqIdInt > 0 {
+    _ = db.Create(&model.Result{ ... }).Error
+}
```



<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
	reqId := c.Query("requestId")
	if reqId != "" {
		// Consider email as opened and create data
		db := config.GetDB()
		reqIdInt, err := strconv.Atoi(reqId)
		if err == nil && reqIdInt > 0 {
			_ = db.Create(&model.Result{
				RequestId: uint(reqIdInt),
				Status:    "Open",
				Raw:       "{}",
			}).Error
		}
	}
`````

</details>

<!-- suggestion_end -->

<details>
<summary>🤖 Prompt for AI Agents (early access)</summary>

```
In aws-ses-sender/api/handler.go around lines 82 to 92, the strconv.Atoi conversion of reqId is unchecked, which can cause a request_id of 0 to be inserted if reqId is non-numeric. Fix this by capturing the error returned by strconv.Atoi, and only proceed to create the Result record if the conversion succeeds without error.
```

</details>

<!-- This is an auto-generated comment by CodeRabbit -->

✅ Addressed in commits 3f51014 to 6c2bdcc
created_at: 2025-05-07T04:55:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,175 @@
+---
+title: AWS SES Email Sender
+keywords: [aws, ses, golang, email, sender]
+description: AWS SES-based Golang email delivery service. Provides email dispatch processing, status tracking, scheduled sending, and result analysis capabilities.
+---
+
+# AWS SES Email Sender
+
+[![Github](https://img.shields.io/static/v1?label=&message=Github&color=2ea44f&style=for-the-badge&logo=github)](https://github.com/gofiber/recipes/tree/master/aws-ses-sender) [![StackBlitz](https://img.shields.io/static/v1?label=&message=StackBlitz&color=2ea44f&style=for-the-badge&logo=StackBlitz)](https://stackblitz.com/github/gofiber/recipes/tree/master/aws-ses-sender)
+
+This is an AWS SES-based Golang email delivery service that extracts only the basic sending functionality from [my open source project](https://github.com/lee-lou2/aws-ses-sender-go).
+
+## Features
+- [x] Email sending using AWS SES
+- [x] Designed with daily limits and per-second sending rates in mind
+- [x] Scheduled sending and message grouping
+- [x] Email open tracking and result collection
+- [x] View daily sending counts and delivery results by message group
+
+### FlowChart
+
+```mermaid
+flowchart TD
+    A[Client] -->|Email Send Request| B[API Server]
+    B -->|DB Storage| C[Scheduler]
+    C -->|Query Pending| D[Sender]
+    D -->|SES Send| E[AWS SES]
+    E -->|Email Received| F[Recipient]
+    E -->|SNS Callback| G[API Server]
+    G -->|DB Storage| H[Results/Statistics]
+    F -->|Open Tracking| I[API Server]
+    I -->|DB Storage| J[Open Events]
+    H -->|Stats/Results Query| K[Client]
+    J -->|Stats/Results Query| K[Client]
+```
+
+## Requirements
+
+### Essential Requirements
+- Go 1.22 or higher
+- AWS account and configuration
+  - AWS SES service activated
+  - Sender email or domain verification completed
+  - IAM user with SES permissions
+  - AWS Access Key and Secret Key
+- PostgreSQL 14.0 or higher
+- (Optional) Docker
+
+### AWS SES Configuration
+1. Verify sender email/domain in AWS SES console
+2. Request removal from SES sandbox mode (for production)
+3. Create SNS topic and set up SES feedback notifications
+4. Grant following permissions to IAM user
+
+## Project Structure
+```
+aws-ses-sender-go/
path: aws-ses-sender/README.md
user: login: coderabbitai[bot]
body: _⚠️ Potential issue_

**Fix project directory name mismatch**  
The **Project Structure** block lists `aws-ses-sender-go/`, but this repo path is actually `aws-ses-sender`. Please update to the correct folder name to avoid confusion.

<details>
<summary>🤖 Prompt for AI Agents (early access)</summary>

```
In aws-ses-sender/README.md at line 57, the project directory name is incorrectly listed as aws-ses-sender-go/. Update this to the correct directory name aws-ses-sender to match the actual repo path and avoid confusion.
```

</details>

<!-- This is an auto-generated comment by CodeRabbit -->

✅ Addressed in commit d07017e
created_at: 2025-05-07T04:55:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,39 @@
+package api
+
+import (
+	"aws-ses-sender-go/config"
+	"fmt"
+	"log"
+
+	"github.com/gofiber/fiber/v3/middleware/pprof"
+
+	"github.com/gofiber/fiber/v3"
+	"github.com/gofiber/fiber/v3/middleware/logger"
+	recoverer "github.com/gofiber/fiber/v3/middleware/recover"
+	jsoniter "github.com/json-iterator/go"
+)
+
+func Run() {
+	app := fiber.New(
+		fiber.Config{
+			AppName: "aws-ses-sender-go",
+			// JSON Encoder and Decoder(Fast JSON library)
+			JSONEncoder: jsoniter.Marshal,
+			JSONDecoder: jsoniter.Unmarshal,
+		},
+	)
+
+	// Middleware
+	app.Use(pprof.New())
+	app.Use(recoverer.New())
+	app.Use(logger.New(logger.Config{
+		Format:     "${time} ${pid} ${status} - ${method} ${path} ${latency}\n",
+		TimeFormat: "2006-01-02 15:04:05",
+		TimeZone:   "Local",
+	}))
+
+	// Routes
+	setV1Routes(app)
+
+	log.Fatal(app.Listen(fmt.Sprintf(":%s", config.GetEnv("SERVER_PORT", "3000"))))
path: aws-ses-sender/api/server.go
user: login: lee-lou2
body: Done! Implemented the multi-stage build as suggested.
Commit: https://github.com/gofiber/recipes/pull/3048/commits/3ac0d4a6239e00b1692386f471085244f6bf6d24
created_at: 2025-05-07T09:42:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,39 @@
+package api
+
+import (
+	"aws-ses-sender-go/config"
+	"fmt"
+	"log"
+
+	"github.com/gofiber/fiber/v3/middleware/pprof"
+
+	"github.com/gofiber/fiber/v3"
+	"github.com/gofiber/fiber/v3/middleware/logger"
+	recoverer "github.com/gofiber/fiber/v3/middleware/recover"
+	jsoniter "github.com/json-iterator/go"
+)
+
+func Run() {
+	app := fiber.New(
+		fiber.Config{
+			AppName: "aws-ses-sender-go",
+			// JSON Encoder and Decoder(Fast JSON library)
+			JSONEncoder: jsoniter.Marshal,
+			JSONDecoder: jsoniter.Unmarshal,
+		},
+	)
+
+	// Middleware
+	app.Use(pprof.New())
+	app.Use(recoverer.New())
+	app.Use(logger.New(logger.Config{
+		Format:     "${time} ${pid} ${status} - ${method} ${path} ${latency}\n",
+		TimeFormat: "2006-01-02 15:04:05",
+		TimeZone:   "Local",
+	}))
+
+	// Routes
+	setV1Routes(app)
+
+	log.Fatal(app.Listen(fmt.Sprintf(":%s", config.GetEnv("SERVER_PORT", "3000"))))
path: aws-ses-sender/api/server.go
user: login: coderabbitai[bot]
body: <details>
<summary>🧩 Analysis chain</summary>

@lee-lou2 Great work implementing the multi-stage build in your Dockerfile! This is an excellent practice that will help reduce the final image size and improve security by not including build tools in the production container.

I noticed this is different from my earlier suggestion about implementing graceful shutdown in the server.go file. Did you want to address that aspect as well, or are you focusing on the Docker optimization for now?



---

🏁 Script executed:
```
#!/bin/bash
# Check the Dockerfile changes in the specified commit
git clone https://github.com/gofiber/recipes.git
cd recipes
git fetch origin pull/3048/head:pr-3048
git checkout pr-3048
git show 3ac0d4a6239e00b1692386f471085244f6bf6d24 -- aws-ses-sender/Dockerfile
```

Length of output: 1419

---



</details>

@lee-lou2 Nice work implementing the multi-stage Docker build! This is an excellent optimization that will significantly reduce your image size, improve security by not including build tools in the runtime container, and speed up deployments. I see you've also updated to golang:1.24 and added the necessary certificates to the alpine image.

I notice this addresses Docker optimization rather than the server.go graceful shutdown suggestion from earlier. Would you also like to implement that improvement, or are you focusing on container optimization for now?

---

<details>
<summary>🧬 Code Graph Analysis Results</summary>

### Relevant snippet from `aws-ses-sender/config/env.go` (lines 14-27)
```go
func GetEnv(key string, defaults ...string) string {
	envOnce.Do(func() {
		if err := godotenv.Load(); err != nil {
			log.Printf("Warning: .env file not found or error loading: %v", err)
		}
	})
	if len(defaults) > 0 {
		if value := os.Getenv(key); value != "" {
			return value
		}
		return defaults[0]
	}
	return os.Getenv(key)
}
```
**Description:**  
This function retrieves environment variables with optional default values. It ensures `.env` file loading happens only once using `envOnce.Do`. If the environment variable `key` is set, it returns that value; otherwise, it returns the provided default if any. If no default is provided and the variable is unset, it returns an empty string. This function is used in `server.go` to get the `SERVER_PORT` environment variable with a default of `"3000"`.

</details>



---

<details>
<summary>✏️ Learnings added</summary>

```
Learnt from: lee-lou2
PR: gofiber/recipes#3048
File: aws-ses-sender/api/server.go:38-38
Timestamp: 2025-05-07T09:44:14.330Z
Learning: lee-lou2 has implemented a multi-stage build pattern in the Dockerfile for the AWS SES Email Sender recipe, showing a preference for optimizing Docker images.
```

---

```
Learnt from: lee-lou2
PR: gofiber/recipes#3048
File: aws-ses-sender/api/server.go:38-38
Timestamp: 2025-05-07T09:44:14.330Z
Learning: lee-lou2 has implemented a multi-stage build pattern in the Dockerfile for the AWS SES Email Sender recipe, showing a preference for optimizing Docker images. The implementation uses golang:1.24 as the builder stage and alpine:3.21 as the runtime stage.
```

> Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure [review instructions](https://docs.coderabbit.ai/guides/review-instructions). You can manage existing learnings in the [app](https://app.coderabbit.ai/learnings).

</details>




<!-- <agentComponentType>chat-answer</agentComponentType> -->

<!-- <rawResChunk><answer><details>
<summary>🧩 Analysis chain</summary>

@lee-lou2 Great work implementing the multi-stage build in your Dockerfile! This is an excellent practice that will help reduce the final image size and improve security by not including build tools in the production container.

I noticed this is different from my earlier suggestion about implementing graceful shutdown in the server.go file. Did you want to address that aspect as well, or are you focusing on the Docker optimization for now?



---

🏁 Script executed:
```
#!/bin/bash
# Check the Dockerfile changes in the specified commit
git clone https://github.com/gofiber/recipes.git
cd recipes
git fetch origin pull/3048/head:pr-3048
git checkout pr-3048
git show 3ac0d4a6239e00b1692386f471085244f6bf6d24 -- aws-ses-sender/Dockerfile
```

Length of output: 1419

---



</details>

@lee-lou2 Nice work implementing the multi-stage Docker build! This is an excellent optimization that will significantly reduce your image size, improve security by not including build tools in the runtime container, and speed up deployments. I see you've also updated to golang:1.24 and added the necessary certificates to the alpine image.

I notice this addresses Docker optimization rather than the server.go graceful shutdown suggestion from earlier. Would you also like to implement that improvement, or are you focusing on container optimization for now?

---

<details>
<summary>🧬 Code Graph Analysis Results</summary>

### Relevant snippet from `aws-ses-sender/config/env.go` (lines 14-27)
```go
func GetEnv(key string, defaults ...string) string {
	envOnce.Do(func() {
		if err := godotenv.Load(); err != nil {
			log.Printf("Warning: .env file not found or error loading: %v", err)
		}
	})
	if len(defaults) > 0 {
		if value := os.Getenv(key); value != "" {
			return value
		}
		return defaults[0]
	}
	return os.Getenv(key)
}
```
**Description:**  
This function retrieves environment variables with optional default values. It ensures `.env` file loading happens only once using `envOnce.Do`. If the environment variable `key` is set, it returns that value; otherwise, it returns the provided default if any. If no default is provided and the variable is unset, it returns an empty string. This function is used in `server.go` to get the `SERVER_PORT` environment variable with a default of `"3000"`.

</details>



---

<details>
<summary>✏️ Learnings added</summary>

```
Learnt from: lee-lou2
PR: gofiber/recipes#3048
File: aws-ses-sender/api/server.go:38-38
Timestamp: 2025-05-07T09:44:14.330Z
Learning: lee-lou2 has implemented a multi-stage build pattern in the Dockerfile for the AWS SES Email Sender recipe, showing a preference for optimizing Docker images.
```

---

```
Learnt from: lee-lou2
PR: gofiber/recipes#3048
File: aws-ses-sender/api/server.go:38-38
Timestamp: 2025-05-07T09:44:14.330Z
Learning: lee-lou2 has implemented a multi-stage build pattern in the Dockerfile for the AWS SES Email Sender recipe, showing a preference for optimizing Docker images. The implementation uses golang:1.24 as the builder stage and alpine:3.21 as the runtime stage.
```

> Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure [review instructions](https://docs.coderabbit.ai/guides/review-instructions). You can manage existing learnings in the [app](https://app.coderabbit.ai/learnings).

</details>

</answer></rawResChunk> -->

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-05-07T09:44:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,13 @@
+FROM golang:1.23
+
+WORKDIR /usr/src/app
+
+COPY go.mod go.sum ./
+RUN go mod download && go mod verify
+
+COPY . .
+RUN go build .
+
path: aws-ses-sender/Dockerfile
user: login: SeungGuk-Ezllabs
body: Refactor Commit: https://github.com/gofiber/recipes/pull/3048/commits/3ac0d4a6239e00b1692386f471085244f6bf6d24
created_at: 2025-05-07T13:27:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,13 @@
+FROM golang:1.23
+
+WORKDIR /usr/src/app
+
+COPY go.mod go.sum ./
+RUN go mod download && go mod verify
+
+COPY . .
+RUN go build .
+
path: aws-ses-sender/Dockerfile
user: login: coderabbitai[bot]
body: <details>
<summary>🧩 Analysis chain</summary>

🏁 Script executed:
```
#!/bin/bash
# Fetch the specific commit information
gh api repos/gofiber/recipes/commits/3ac0d4a6239e00b1692386f471085244f6bf6d24 --jq '.commit.message'
# Get the actual changes in the commit
gh api repos/gofiber/recipes/commits/3ac0d4a6239e00b1692386f471085244f6bf6d24 | jq -r '.files[] | select(.filename == "aws-ses-sender/Dockerfile") | .patch'
```

Length of output: 942

---



</details>

@SeungGuk-Ezllabs Thank you for sharing the refactor commit! I see you've successfully implemented the multi-stage build pattern with some nice improvements:

- Using golang:1.24 (the latest version) for your builder stage
- Specifying alpine:3.21 for your runtime image (good for reproducibility)
- Adding essential ca-certificates for proper SSL/TLS support
- Optimizing the build with CGO_ENABLED=0 and other flags

This implementation will significantly reduce your final image size by excluding the Go toolchain and build dependencies from the runtime container. The approach aligns well with your previously demonstrated preference for optimized Docker images.

---

<details>
<summary>🧠 Learnings used</summary>

```
Learnt from: lee-lou2
PR: gofiber/recipes#3048
File: aws-ses-sender/api/server.go:38-38
Timestamp: 2025-05-07T09:44:14.330Z
Learning: lee-lou2 has implemented a multi-stage build pattern in the Dockerfile for the AWS SES Email Sender recipe, showing a preference for optimizing Docker images. The implementation uses golang:1.24 as the builder stage and alpine:3.21 as the runtime stage.
```

</details>




<!-- <agentComponentType>chat-answer</agentComponentType> -->

<!-- <rawResChunk><answer><details>
<summary>🧩 Analysis chain</summary>

🏁 Script executed:
```
#!/bin/bash
# Fetch the specific commit information
gh api repos/gofiber/recipes/commits/3ac0d4a6239e00b1692386f471085244f6bf6d24 --jq '.commit.message'
# Get the actual changes in the commit
gh api repos/gofiber/recipes/commits/3ac0d4a6239e00b1692386f471085244f6bf6d24 | jq -r '.files[] | select(.filename == "aws-ses-sender/Dockerfile") | .patch'
```

Length of output: 942

---



</details>

@SeungGuk-Ezllabs Thank you for sharing the refactor commit! I see you've successfully implemented the multi-stage build pattern with some nice improvements:

- Using golang:1.24 (the latest version) for your builder stage
- Specifying alpine:3.21 for your runtime image (good for reproducibility)
- Adding essential ca-certificates for proper SSL/TLS support
- Optimizing the build with CGO_ENABLED=0 and other flags

This implementation will significantly reduce your final image size by excluding the Go toolchain and build dependencies from the runtime container. The approach aligns well with your previously demonstrated preference for optimized Docker images.

---

<details>
<summary>🧠 Learnings used</summary>

```
Learnt from: lee-lou2
PR: gofiber/recipes#3048
File: aws-ses-sender/api/server.go:38-38
Timestamp: 2025-05-07T09:44:14.330Z
Learning: lee-lou2 has implemented a multi-stage build pattern in the Dockerfile for the AWS SES Email Sender recipe, showing a preference for optimizing Docker images. The implementation uses golang:1.24 as the builder stage and alpine:3.21 as the runtime stage.
```

</details>

</answer></rawResChunk> -->

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-05-07T13:28:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,20 @@
+package api
+
+import (
+	"aws-ses-sender/config"
+
+	"github.com/gofiber/fiber/v3"
+)
+
+// apiKeyAuth API Key Authentication Middleware
+// Middleware to check for the API key in the request header
+func apiKeyAuth(c fiber.Ctx) error {
+	apiKey := c.Get("x-api-key")
+	expectedAPIKey := config.GetEnv("API_KEY", "")
+	if apiKey != expectedAPIKey {
+		return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
+			"error": "Unauthorized",
+		})
+	}
+	return c.Next()
+}
path: aws-ses-sender/api/middlewares.go
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Consider adding a timing-safe comparison for API key authentication**

The current implementation uses a direct string comparison for API key verification. For security-sensitive comparisons like API keys, consider using a constant-time comparison to prevent timing attacks.


```diff
func apiKeyAuth(c fiber.Ctx) error {
	apiKey := c.Get("x-api-key")
	expectedAPIKey := config.GetEnv("API_KEY", "")
-	if apiKey != expectedAPIKey {
+	// Use a constant-time comparison to prevent timing attacks
+	if expectedAPIKey == "" || !crypto/subtle.ConstantTimeCompare([]byte(apiKey), []byte(expectedAPIKey)) {
		return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
			"error": "Unauthorized",
		})
	}
	return c.Next()
}
```

Don't forget to add the import for `crypto/subtle`.


> Committable suggestion skipped: line range outside the PR's diff.

<details>
<summary>🤖 Prompt for AI Agents (early access)</summary>

```
In aws-ses-sender/api/middlewares.go around lines 9 to 20, replace the direct
string comparison of the API key with a constant-time comparison using
crypto/subtle.ConstantTimeCompare to prevent timing attacks. Import the
"crypto/subtle" package and use subtle.ConstantTimeCompare on the byte slices of
the provided and expected API keys, returning unauthorized if they do not match.
```

</details>

<!-- This is an auto-generated comment by CodeRabbit -->

✅ Addressed in commit 95b2f45
created_at: 2025-05-07T13:30:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,64 @@
+package cmd
+
+import (
+	"aws-ses-sender/config"
+	"aws-ses-sender/model"
+	"context"
+	"log"
+	"strconv"
+	"time"
+)
+
+// RunScheduler runs the scheduler
+// It schedules the email sending requests to be processed by the sender
+func RunScheduler(ctx context.Context) {
+	db := config.GetDB()
+	sendPerSecStr := config.GetEnv("EMAIL_RATE", "14")
+	sendPerSec, _ := strconv.Atoi(sendPerSecStr)
+	sendPerMin := sendPerSec * 60
+	batchSize := 1000
path: aws-ses-sender/cmd/scheduler.go
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Improve error handling for environment variable parsing**

The current implementation ignores the error from `strconv.Atoi()`, which could lead to unexpected behavior if the EMAIL_RATE environment variable isn't a valid integer.


```diff
func RunScheduler(ctx context.Context) {
	db := config.GetDB()
	sendPerSecStr := config.GetEnv("EMAIL_RATE", "14")
-	sendPerSec, _ := strconv.Atoi(sendPerSecStr)
+	sendPerSec, err := strconv.Atoi(sendPerSecStr)
+	if err != nil {
+		log.Printf("Invalid EMAIL_RATE value '%s', defaulting to 14: %v", sendPerSecStr, err)
+		sendPerSec = 14
+	}
	sendPerMin := sendPerSec * 60
	batchSize := 1000
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
func RunScheduler(ctx context.Context) {
    db := config.GetDB()
    sendPerSecStr := config.GetEnv("EMAIL_RATE", "14")
    sendPerSec, err := strconv.Atoi(sendPerSecStr)
    if err != nil {
        log.Printf("Invalid EMAIL_RATE value '%s', defaulting to 14: %v", sendPerSecStr, err)
        sendPerSec = 14
    }
    sendPerMin := sendPerSec * 60
    batchSize := 1000
    // ...
}
`````

</details>

<!-- suggestion_end -->

<details>
<summary>🤖 Prompt for AI Agents (early access)</summary>

```
In aws-ses-sender/cmd/scheduler.go around lines 15 to 19, the code ignores the
error returned by strconv.Atoi when parsing the EMAIL_RATE environment variable.
Modify the code to check the error returned by strconv.Atoi, and if an error
occurs, handle it appropriately by either setting a default value or logging the
error and exiting. This ensures the program behaves predictably even if the
environment variable is invalid.
```

</details>

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-05-07T13:30:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,176 @@
+---
+title: AWS SES Email Sender
+keywords: [aws, ses, golang, email, sender]
+description: AWS SES-based Golang email delivery service. Provides email dispatch processing, status tracking, scheduled sending, and result analysis capabilities.
+---
+
+# AWS SES Email Sender
+
+[![Github](https://img.shields.io/static/v1?label=&message=Github&color=2ea44f&style=for-the-badge&logo=github)](https://github.com/gofiber/recipes/tree/master/aws-ses-sender) [![StackBlitz](https://img.shields.io/static/v1?label=&message=StackBlitz&color=2ea44f&style=for-the-badge&logo=StackBlitz)](https://stackblitz.com/github/gofiber/recipes/tree/master/aws-ses-sender)
+
+This is an AWS SES-based Golang email delivery service that extracts only the basic sending functionality from [my open source project](https://github.com/lee-lou2/aws-ses-sender-go).
+
+## Features
+- [x] Email sending using AWS SES
+- [x] Designed with daily limits and per-second sending rates in mind
+- [x] Scheduled sending and message grouping
+- [x] Email open tracking and result collection
+- [x] View daily sending counts and delivery results by message group
+
+### FlowChart
+
+```mermaid
+flowchart TD
+    A[Client] -->|Email Send Request| B[API Server]
+    B -->|DB Storage| C[Scheduler]
+    C -->|Query Pending| D[Sender]
+    D -->|SES Send| E[AWS SES]
+    E -->|Email Received| F[Recipient]
+    E -->|SNS Callback| G[API Server]
+    G -->|DB Storage| H[Results/Statistics]
+    F -->|Open Tracking| I[API Server]
+    I -->|DB Storage| J[Open Events]
+    H -->|Stats/Results Query| K[Client]
+    J -->|Stats/Results Query| K[Client]
+```
+
+## Requirements
+
+### Essential Requirements
+- Go 1.22 or higher
+- AWS account and configuration
+  - AWS SES service activated
+  - Sender email or domain verification completed
+  - IAM user with SES permissions
+  - AWS Access Key and Secret Key
+- PostgreSQL 14.0 or higher
+- (Optional) Docker
+
+### AWS SES Configuration
+1. Verify sender email/domain in AWS SES console
+2. Request removal from SES sandbox mode (for production)
+3. Create SNS topic and set up SES feedback notifications
+4. Grant following permissions to IAM user
+
path: aws-ses-sender/README.md
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Incomplete IAM permissions section**

The README mentions "Grant following permissions to IAM user" but doesn't list the specific permissions needed.


Complete the IAM permissions section by adding the specific AWS permissions required:

```diff
4. Grant following permissions to IAM user
+   - `ses:SendEmail`
+   - `ses:SendRawEmail`
+   - `sns:Publish` (if using SNS for delivery notifications)
+   - `sns:Subscribe` (if using SNS for delivery notifications)
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
4. Grant following permissions to IAM user
   - `ses:SendEmail`
   - `ses:SendRawEmail`
   - `sns:Publish` (if using SNS for delivery notifications)
   - `sns:Subscribe` (if using SNS for delivery notifications)
`````

</details>

<!-- suggestion_end -->

<details>
<summary>🤖 Prompt for AI Agents (early access)</summary>

```
In aws-ses-sender/README.md around lines 53 to 54, the IAM permissions section
is incomplete as it mentions granting permissions but does not list them. Add a
detailed list of the specific AWS IAM permissions required for the user, such as
SES sending permissions and any other necessary actions, to clearly guide users
on what permissions to assign.
```

</details>

<!-- This is an auto-generated comment by CodeRabbit -->

✅ Addressed in commits e0d5ecf to 5ceb8d8
created_at: 2025-05-07T13:30:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,42 @@
+package config
+
+import (
+	"fmt"
+	"log"
+	"sync"
+
+	"gorm.io/driver/postgres"
+	"gorm.io/gorm"
+	"gorm.io/gorm/logger"
+)
+
+var (
+	dbInstance *gorm.DB
+	dbOnce     sync.Once
+)
+
+// GetDB Database instance
+func GetDB() *gorm.DB {
+	dbOnce.Do(func() {
+		host := GetEnv("DB_HOST", "localhost")
+		port := GetEnv("DB_PORT", "5432")
+		user := GetEnv("DB_USER", "postgres")
+		password := GetEnv("DB_PASSWORD", "postgres")
+		dbname := GetEnv("DB_NAME", "postgres")
+		dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=disable TimeZone=Asia/Seoul",
+			host, user, password, dbname, port)
+		db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
+			Logger: logger.Default.LogMode(logger.Error),
+		})
+		if err != nil {
+			log.Fatalf("Failed to connect to database: %v", err)
+		}
+		sqlDB, err := db.DB()
+		if err != nil {
+			log.Fatalf("Failed to get database instance: %v", err)
+		}
+		sqlDB.SetMaxOpenConns(10)
+		dbInstance = db
+	})
+	return dbInstance
+}
path: aws-ses-sender/config/db.go
user: login: SeungGuk-Ezllabs
body: Refactor Commit: https://github.com/gofiber/recipes/pull/3048/commits/9c661ec9e50aea3e1e02d871ccef852ba43545e1
created_at: 2025-05-07T13:30:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,50 @@
+package model
+
+import (
+	"aws-ses-sender-go/config"
+	"time"
+
+	"gorm.io/gorm"
+)
+
+const (
+	EmailMessageStatusCreated    = iota // Creation complete
+	EmailMessageStatusProcessing        // Processing
+	EmailMessageStatusSent              // Sent
+	EmailMessageStatusFailed            // Failed
+	EmailMessageStatusStopped           // Stopped
+)
+
+type Request struct {
+	gorm.Model
+	TopicId     string     `json:"topic_id" gorm:"index;null"`
+	MessageId   string     `json:"message_id" gorm:"index;null;type:varchar(255)"`
+	To          string     `json:"to" gorm:"not null;type:varchar(255)"`
+	Subject     string     `json:"subject" gorm:"not null;type:varchar(255)"`
+	Content     string     `json:"content" gorm:"not null;type:text"`
+	ScheduledAt *time.Time `json:"scheduled_at" gorm:"null;index;type:timestamp with time zone"`
+	Status      int        `json:"status" gorm:"default:0;index;not null;type:smallint"`
+	Error       string     `json:"error" gorm:"null;type:varchar(255)"`
+}
path: aws-ses-sender/model/email.go
user: login: SeungGuk-Ezllabs
body: Refactor Commit: https://github.com/gofiber/recipes/pull/3048/commits/ca356c32903d377573e789e3ebf4df5c7d865e46
created_at: 2025-05-07T13:30:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,42 @@
+package config
+
+import (
+	"fmt"
+	"log"
+	"sync"
+
+	"gorm.io/driver/postgres"
+	"gorm.io/gorm"
+	"gorm.io/gorm/logger"
+)
+
+var (
+	dbInstance *gorm.DB
+	dbOnce     sync.Once
+)
+
+// GetDB Database instance
+func GetDB() *gorm.DB {
+	dbOnce.Do(func() {
+		host := GetEnv("DB_HOST", "localhost")
+		port := GetEnv("DB_PORT", "5432")
+		user := GetEnv("DB_USER", "postgres")
+		password := GetEnv("DB_PASSWORD", "postgres")
+		dbname := GetEnv("DB_NAME", "postgres")
+		dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=disable TimeZone=Asia/Seoul",
+			host, user, password, dbname, port)
+		db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
+			Logger: logger.Default.LogMode(logger.Error),
+		})
+		if err != nil {
+			log.Fatalf("Failed to connect to database: %v", err)
+		}
+		sqlDB, err := db.DB()
+		if err != nil {
+			log.Fatalf("Failed to get database instance: %v", err)
+		}
+		sqlDB.SetMaxOpenConns(10)
+		dbInstance = db
+	})
+	return dbInstance
+}
path: aws-ses-sender/config/db.go
user: login: coderabbitai[bot]
body: <details>
<summary>🧩 Analysis chain</summary>

@SeungGuk-Ezllabs Thank you for implementing the database connection closing mechanism! I can see you've added the `CloseDB` function to properly close connections and also updated the main application to call it during shutdown. This will help prevent connection leaks and ensure graceful termination of database resources when the application exits.

---

🏁 Script executed:
```
#!/bin/bash
# Check if CloseDB was implemented in config/db.go
cat aws-ses-sender/config/db.go | grep -A 15 "CloseDB"

# Check if main.go calls CloseDB during shutdown
cat aws-ses-sender/main.go | grep -A 5 -B 5 "CloseDB"
```

Length of output: 548

---



</details>

@SeungGuk-Ezllabs Thanks for implementing the database connection closing mechanism! I've verified that you've successfully added both the `CloseDB` function to handle graceful shutdown of database connections and properly integrated it in the main application. This will help prevent resource leaks and ensure clean termination when the application exits.

---

<details>
<summary>🧬 Code Graph Analysis Results</summary>

### Relevant snippet from `aws-ses-sender/config/env.go` lines 14-27:
```go
func GetEnv(key string, defaults ...string) string {
	envOnce.Do(func() {
		if err := godotenv.Load(); err != nil {
			log.Printf("Warning: .env file not found or error loading: %v", err)
		}
	})
	if len(defaults) > 0 {
		if value := os.Getenv(key); value != "" {
			return value
		}
		return defaults[0]
	}
	return os.Getenv(key)
}
```
- This function retrieves environment variables with optional default values.
- It loads environment variables from a `.env` file once using `godotenv.Load()`.
- If the environment variable is not set, it returns the provided default value if any.
- Used in `db.go` to get database configuration parameters like host, port, user, password, dbname, and timezone.

</details>




<!-- <agentComponentType>chat-answer</agentComponentType> -->

<!-- <rawResChunk><answer><details>
<summary>🧩 Analysis chain</summary>

@SeungGuk-Ezllabs Thank you for implementing the database connection closing mechanism! I can see you've added the `CloseDB` function to properly close connections and also updated the main application to call it during shutdown. This will help prevent connection leaks and ensure graceful termination of database resources when the application exits.

---

🏁 Script executed:
```
#!/bin/bash
# Check if CloseDB was implemented in config/db.go
cat aws-ses-sender/config/db.go | grep -A 15 "CloseDB"

# Check if main.go calls CloseDB during shutdown
cat aws-ses-sender/main.go | grep -A 5 -B 5 "CloseDB"
```

Length of output: 548

---



</details>

@SeungGuk-Ezllabs Thanks for implementing the database connection closing mechanism! I've verified that you've successfully added both the `CloseDB` function to handle graceful shutdown of database connections and properly integrated it in the main application. This will help prevent resource leaks and ensure clean termination when the application exits.

---

<details>
<summary>🧬 Code Graph Analysis Results</summary>

### Relevant snippet from `aws-ses-sender/config/env.go` lines 14-27:
```go
func GetEnv(key string, defaults ...string) string {
	envOnce.Do(func() {
		if err := godotenv.Load(); err != nil {
			log.Printf("Warning: .env file not found or error loading: %v", err)
		}
	})
	if len(defaults) > 0 {
		if value := os.Getenv(key); value != "" {
			return value
		}
		return defaults[0]
	}
	return os.Getenv(key)
}
```
- This function retrieves environment variables with optional default values.
- It loads environment variables from a `.env` file once using `godotenv.Load()`.
- If the environment variable is not set, it returns the provided default value if any.
- Used in `db.go` to get database configuration parameters like host, port, user, password, dbname, and timezone.

</details>

</answer></rawResChunk> -->

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-05-07T13:31:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,50 @@
+package model
+
+import (
+	"aws-ses-sender-go/config"
+	"time"
+
+	"gorm.io/gorm"
+)
+
+const (
+	EmailMessageStatusCreated    = iota // Creation complete
+	EmailMessageStatusProcessing        // Processing
+	EmailMessageStatusSent              // Sent
+	EmailMessageStatusFailed            // Failed
+	EmailMessageStatusStopped           // Stopped
+)
+
+type Request struct {
+	gorm.Model
+	TopicId     string     `json:"topic_id" gorm:"index;null"`
+	MessageId   string     `json:"message_id" gorm:"index;null;type:varchar(255)"`
+	To          string     `json:"to" gorm:"not null;type:varchar(255)"`
+	Subject     string     `json:"subject" gorm:"not null;type:varchar(255)"`
+	Content     string     `json:"content" gorm:"not null;type:text"`
+	ScheduledAt *time.Time `json:"scheduled_at" gorm:"null;index;type:timestamp with time zone"`
+	Status      int        `json:"status" gorm:"default:0;index;not null;type:smallint"`
+	Error       string     `json:"error" gorm:"null;type:varchar(255)"`
+}
path: aws-ses-sender/model/email.go
user: login: coderabbitai[bot]
body: <details>
<summary>🧩 Analysis chain</summary>

@SeungGuk-Ezllabs Thanks for implementing the changes! Let me check your commit.

---

🏁 Script executed:
```
#!/bin/bash

# Fetching the commit details
curl -s "https://api.github.com/repos/gofiber/recipes/commits/ca356c32903d377573e789e3ebf4df5c7d865e46" | jq -r '.files[] | select(.filename == "aws-ses-sender/model/email.go") | .patch'
```

Length of output: 1125

---



</details>

@SeungGuk-Ezllabs I've reviewed your changes, and they look good! You've successfully:

1. Fixed the Go naming convention by changing `TopicId` to `TopicID` and `MessageId` to `MessageID`
2. Removed the invalid `null` GORM tag options
3. Used default empty string values instead of pointer types

Using default values (`default:''`) is a perfectly valid alternative to using pointer types. This approach ensures fields store empty strings rather than NULL values, which can simplify your application code by eliminating nil pointer checks.

Great job implementing the fix! 👍

🐰✨

---

<details>
<summary>🧬 Code Graph Analysis Results</summary>

### Possibly relevant snippets from `aws-ses-sender/config/db.go` (lines 19-44):

```go
func GetDB() *gorm.DB {
	dbOnce.Do(func() {
		host := GetEnv("DB_HOST", "localhost")
		port := GetEnv("DB_PORT", "5432")
		user := GetEnv("DB_USER", "postgres")
		password := GetEnv("DB_PASSWORD", "postgres")
		dbname := GetEnv("DB_NAME", "postgres")

		timezone := GetEnv("DB_TIMEZONE", "UTC")
		dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=disable TimeZone=%s",
			host, user, password, dbname, port, timezone)
		db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
			Logger: logger.Default.LogMode(logger.Error),
		})
		if err != nil {
			log.Fatalf("Failed to connect to database: %v", err)
		}
		sqlDB, err := db.DB()
		if err != nil {
			log.Fatalf("Failed to get database instance: %v", err)
		}
		sqlDB.SetMaxOpenConns(10)
		dbInstance = db
	})
	return dbInstance
}
```

**Summary:**

- `GetDB()` initializes and returns a singleton GORM database connection instance.
- Reads database connection parameters from environment variables with defaults.
- Constructs a PostgreSQL DSN string including timezone.
- Opens a GORM connection with error logging level set to `Error`.
- Configures the underlying SQL DB connection pool with a max of 10 open connections.
- On failure to connect or get DB instance, logs fatal errors terminating the program.
- Used in `email.go` to get the DB instance for automigrating the `Request` and `Result` models.

</details>




<!-- <agentComponentType>chat-answer</agentComponentType> -->

<!-- <rawResChunk><answer><details>
<summary>🧩 Analysis chain</summary>

@SeungGuk-Ezllabs Thanks for implementing the changes! Let me check your commit.

---

🏁 Script executed:
```
#!/bin/bash

# Fetching the commit details
curl -s "https://api.github.com/repos/gofiber/recipes/commits/ca356c32903d377573e789e3ebf4df5c7d865e46" | jq -r '.files[] | select(.filename == "aws-ses-sender/model/email.go") | .patch'
```

Length of output: 1125

---



</details>

@SeungGuk-Ezllabs I've reviewed your changes, and they look good! You've successfully:

1. Fixed the Go naming convention by changing `TopicId` to `TopicID` and `MessageId` to `MessageID`
2. Removed the invalid `null` GORM tag options
3. Used default empty string values instead of pointer types

Using default values (`default:''`) is a perfectly valid alternative to using pointer types. This approach ensures fields store empty strings rather than NULL values, which can simplify your application code by eliminating nil pointer checks.

Great job implementing the fix! 👍

🐰✨

---

<details>
<summary>🧬 Code Graph Analysis Results</summary>

### Possibly relevant snippets from `aws-ses-sender/config/db.go` (lines 19-44):

```go
func GetDB() *gorm.DB {
	dbOnce.Do(func() {
		host := GetEnv("DB_HOST", "localhost")
		port := GetEnv("DB_PORT", "5432")
		user := GetEnv("DB_USER", "postgres")
		password := GetEnv("DB_PASSWORD", "postgres")
		dbname := GetEnv("DB_NAME", "postgres")

		timezone := GetEnv("DB_TIMEZONE", "UTC")
		dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=disable TimeZone=%s",
			host, user, password, dbname, port, timezone)
		db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
			Logger: logger.Default.LogMode(logger.Error),
		})
		if err != nil {
			log.Fatalf("Failed to connect to database: %v", err)
		}
		sqlDB, err := db.DB()
		if err != nil {
			log.Fatalf("Failed to get database instance: %v", err)
		}
		sqlDB.SetMaxOpenConns(10)
		dbInstance = db
	})
	return dbInstance
}
```

**Summary:**

- `GetDB()` initializes and returns a singleton GORM database connection instance.
- Reads database connection parameters from environment variables with defaults.
- Constructs a PostgreSQL DSN string including timezone.
- Opens a GORM connection with error logging level set to `Error`.
- Configures the underlying SQL DB connection pool with a max of 10 open connections.
- On failure to connect or get DB instance, logs fatal errors terminating the program.
- Used in `email.go` to get the DB instance for automigrating the `Request` and `Result` models.

</details>

</answer></rawResChunk> -->

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-05-07T13:32:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,293 @@
+package api
+
+import (
+	"aws-ses-sender/config"
+	"aws-ses-sender/model"
+	"bytes"
+	"encoding/json"
+	"image"
+	"image/color"
+	"image/png"
+	"log"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/gofiber/fiber/v3"
+)
+
+// createMessageHandler Message Handler
+// Handler that receives email sending requests
+func createMessageHandler(c fiber.Ctx) error {
+	start := time.Now()
+	var reqBody struct {
+		Messages []struct {
+			TopicId     string   `json:"topicId"`
+			Emails      []string `json:"emails"`
+			Subject     string   `json:"subject"`
+			Content     string   `json:"content"`
+			ScheduledAt string   `json:"scheduledAt"`
+		} `json:"messages"`
+	}
+	if err := c.Bind().JSON(&reqBody); err != nil {
+		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
+	}
+
+	db := config.GetDB()
+	reqs := make([]*model.Request, 0)
+	totCnt := 0
+	for _, msg := range reqBody.Messages {
+		scheduledAt := time.Now().UTC()
+		if msg.ScheduledAt != "" {
+			if t, err := time.Parse(time.RFC3339, msg.ScheduledAt); err != nil {
+				return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid scheduledAt format"})
+			} else {
+				scheduledAt = t.UTC()
+			}
+		}
+		for _, email := range msg.Emails {
+			req := &model.Request{
+				TopicId:     msg.TopicId,
+				To:          email,
+				Subject:     msg.Subject,
+				Content:     msg.Content,
+				ScheduledAt: &scheduledAt,
+				Status:      model.EmailMessageStatusCreated,
+			}
+			reqs = append(reqs, req)
+			totCnt++
+		}
+	}
+
+	chunkSize := 1000
+	for i := 0; i < len(reqs); i += chunkSize {
+		end := i + chunkSize
+		if end > len(reqs) {
+			end = len(reqs)
+		}
+		if err := db.Create(reqs[i:end]).Error; err != nil {
+			return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+		}
+	}
+
+	// Return the result
+	return c.JSON(fiber.Map{
+		"count":   len(reqBody.Messages),
+		"elapsed": time.Since(start).String(),
+	})
path: aws-ses-sender/api/handler.go
user: login: coderabbitai[bot]
body: _⚠️ Potential issue_

**`count` field returns *messages*, not *requests***

`totCnt` tracks the actual number of individual requests created, but the JSON response currently returns `len(reqBody.Messages)` (number of *message groups*).  
API consumers will mis-interpret the value.

```diff
-    "count":   len(reqBody.Messages),
+    "count":   totCnt,
```



<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
	return c.JSON(fiber.Map{
		"count":   totCnt,
		"elapsed": time.Since(start).String(),
	})
`````

</details>

<!-- suggestion_end -->

<details>
<summary>🤖 Prompt for AI Agents (early access)</summary>

```
In aws-ses-sender/api/handler.go around lines 74 to 77, the JSON response's
"count" field incorrectly returns the number of message groups using
len(reqBody.Messages) instead of the total number of individual requests tracked
by totCnt. Update the "count" field in the returned JSON to use totCnt so that
it accurately reflects the total number of requests created, preventing
misinterpretation by API consumers.
```

</details>

<!-- This is an auto-generated comment by CodeRabbit -->

✅ Addressed in commits e0d5ecf to 5ceb8d8
created_at: 2025-05-07T23:16:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,293 @@
+package api
+
+import (
+	"aws-ses-sender/config"
+	"aws-ses-sender/model"
+	"bytes"
+	"encoding/json"
+	"image"
+	"image/color"
+	"image/png"
+	"log"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/gofiber/fiber/v3"
+)
+
+// createMessageHandler Message Handler
+// Handler that receives email sending requests
+func createMessageHandler(c fiber.Ctx) error {
+	start := time.Now()
+	var reqBody struct {
+		Messages []struct {
+			TopicId     string   `json:"topicId"`
+			Emails      []string `json:"emails"`
+			Subject     string   `json:"subject"`
+			Content     string   `json:"content"`
+			ScheduledAt string   `json:"scheduledAt"`
+		} `json:"messages"`
+	}
+	if err := c.Bind().JSON(&reqBody); err != nil {
+		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
+	}
+
+	db := config.GetDB()
+	reqs := make([]*model.Request, 0)
+	totCnt := 0
+	for _, msg := range reqBody.Messages {
+		scheduledAt := time.Now().UTC()
+		if msg.ScheduledAt != "" {
+			if t, err := time.Parse(time.RFC3339, msg.ScheduledAt); err != nil {
+				return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid scheduledAt format"})
+			} else {
+				scheduledAt = t.UTC()
+			}
+		}
+		for _, email := range msg.Emails {
+			req := &model.Request{
+				TopicId:     msg.TopicId,
+				To:          email,
+				Subject:     msg.Subject,
+				Content:     msg.Content,
+				ScheduledAt: &scheduledAt,
+				Status:      model.EmailMessageStatusCreated,
+			}
+			reqs = append(reqs, req)
+			totCnt++
+		}
path: aws-ses-sender/api/handler.go
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Missing email address validation**

Every entry in `msg.Emails` is inserted as-is. A single malformed address (`"foo@@bar"`) will travel through the pipeline, eventually producing hard bounces and skewing statistics.

Consider rejecting bad addresses up-front, e.g.:

```diff
+import "net/mail"
 …
- for _, email := range msg.Emails {
+ for _, email := range msg.Emails {
+     if _, err := mail.ParseAddress(email); err != nil {
+         return c.Status(fiber.StatusBadRequest).
+             JSON(fiber.Map{"error": fmt.Sprintf("invalid email: %s", email)})
+     }
```

This keeps the DB clean and prevents needless SES traffic.  



<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
        for _, email := range msg.Emails {
            if _, err := mail.ParseAddress(email); err != nil {
                return c.Status(fiber.StatusBadRequest).
                    JSON(fiber.Map{"error": fmt.Sprintf("invalid email: %s", email)})
            }
            req := &model.Request{
                TopicId:     msg.TopicId,
                To:          email,
                Subject:     msg.Subject,
                Content:     msg.Content,
                ScheduledAt: &scheduledAt,
                Status:      model.EmailMessageStatusCreated,
            }
            reqs = append(reqs, req)
            totCnt++
        }
`````

</details>

<!-- suggestion_end -->

<details>
<summary>🤖 Prompt for AI Agents (early access)</summary>

```
In aws-ses-sender/api/handler.go around lines 48 to 59, the code appends every
email from msg.Emails without validating the email format, which can lead to
malformed addresses causing issues downstream. Add validation logic to check
each email address format before creating and appending the request; if an email
is invalid, skip it or handle it appropriately to prevent inserting bad
addresses into the database and sending them to SES.
```

</details>

<!-- This is an auto-generated comment by CodeRabbit -->

✅ Addressed in commits e0d5ecf to 5ceb8d8
created_at: 2025-05-07T23:16:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,293 @@
+package api
+
+import (
+	"aws-ses-sender/config"
+	"aws-ses-sender/model"
+	"bytes"
+	"encoding/json"
+	"image"
+	"image/color"
+	"image/png"
+	"log"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/gofiber/fiber/v3"
+)
+
+// createMessageHandler Message Handler
+// Handler that receives email sending requests
+func createMessageHandler(c fiber.Ctx) error {
+	start := time.Now()
+	var reqBody struct {
+		Messages []struct {
+			TopicId     string   `json:"topicId"`
+			Emails      []string `json:"emails"`
+			Subject     string   `json:"subject"`
+			Content     string   `json:"content"`
+			ScheduledAt string   `json:"scheduledAt"`
+		} `json:"messages"`
+	}
+	if err := c.Bind().JSON(&reqBody); err != nil {
+		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
+	}
+
+	db := config.GetDB()
+	reqs := make([]*model.Request, 0)
+	totCnt := 0
+	for _, msg := range reqBody.Messages {
+		scheduledAt := time.Now().UTC()
+		if msg.ScheduledAt != "" {
+			if t, err := time.Parse(time.RFC3339, msg.ScheduledAt); err != nil {
+				return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid scheduledAt format"})
+			} else {
+				scheduledAt = t.UTC()
+			}
+		}
+		for _, email := range msg.Emails {
+			req := &model.Request{
+				TopicId:     msg.TopicId,
+				To:          email,
+				Subject:     msg.Subject,
+				Content:     msg.Content,
+				ScheduledAt: &scheduledAt,
+				Status:      model.EmailMessageStatusCreated,
+			}
+			reqs = append(reqs, req)
+			totCnt++
+		}
+	}
+
+	chunkSize := 1000
+	for i := 0; i < len(reqs); i += chunkSize {
+		end := i + chunkSize
+		if end > len(reqs) {
+			end = len(reqs)
+		}
+		if err := db.Create(reqs[i:end]).Error; err != nil {
+			return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+		}
+	}
+
+	// Return the result
+	return c.JSON(fiber.Map{
+		"count":   len(reqBody.Messages),
+		"elapsed": time.Since(start).String(),
+	})
+}
+
+// createOpenEventHandler Open Event Handler
+// Attach an image script to the email and assume it has been read when the image is accessed
+func createOpenEventHandler(c fiber.Ctx) error {
+	reqId := c.Query("requestId")
+	if reqId != "" {
+		// Consider email as opened and create data
+		db := config.GetDB()
+		if reqIdInt, err := strconv.Atoi(reqId); err == nil {
+			_ = db.Create(&model.Result{
+				RequestId: uint(reqIdInt),
+				Status:    "Open",
+				Raw:       "{}",
+			}).Error
+		} else {
+			log.Printf("Invalid requestId: %s", reqId)
+		}
+	}
path: aws-ses-sender/api/handler.go
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Ignored DB error may hide persistence failures**

The result of `db.Create(&model.Result{…}).Error` is dropped.  
If the insert fails (e.g., FK violation, DB outage) the handler still replies with a 1×1 PNG → silently losing open-tracking data.

```diff
- _ = db.Create(&model.Result{ … }).Error
+if err := db.Create(&model.Result{ … }).Error; err != nil {
+    log.Printf("failed to store open event: %v", err)
+}
```

At minimum, log the error; ideally, return a 5xx so monitoring can detect the problem.

<details>
<summary>🤖 Prompt for AI Agents (early access)</summary>

```
In aws-ses-sender/api/handler.go around lines 87 to 96, the error returned by
db.Create is ignored, which can hide database persistence failures. Modify the
code to check the error returned by db.Create, log the error if it occurs, and
ideally respond with a 5xx HTTP status code to indicate a server error, ensuring
that failures are not silently ignored and can be detected by monitoring.
```

</details>

<!-- This is an auto-generated comment by CodeRabbit -->

✅ Addressed in commits e0d5ecf to 5ceb8d8
created_at: 2025-05-07T23:16:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,302 @@
+package api
+
+import (
+	"aws-ses-sender/config"
+	"aws-ses-sender/model"
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"image"
+	"image/color"
+	"image/png"
+	"log"
+	"net/mail"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/gofiber/fiber/v3"
+)
+
+// createMessageHandler Message Handler
+// Handler that receives email sending requests
+func createMessageHandler(c fiber.Ctx) error {
+	start := time.Now()
+	var reqBody struct {
+		Messages []struct {
+			TopicId     string   `json:"topicId"`
+			Emails      []string `json:"emails"`
+			Subject     string   `json:"subject"`
+			Content     string   `json:"content"`
+			ScheduledAt string   `json:"scheduledAt"`
+		} `json:"messages"`
+	}
+	if err := c.Bind().JSON(&reqBody); err != nil {
+		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
+	}
+
+	db := config.GetDB()
+	reqs := make([]*model.Request, 0)
+	totCnt := 0
+	for _, msg := range reqBody.Messages {
+		scheduledAt := time.Now().UTC()
+		if msg.ScheduledAt != "" {
+			if t, err := time.Parse(time.RFC3339, msg.ScheduledAt); err != nil {
+				return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid scheduledAt format"})
+			} else {
+				scheduledAt = t.UTC()
+			}
+		}
path: aws-ses-sender/api/handler.go
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Validate future scheduling times.**

The code accepts any valid timestamp for `scheduledAt`, including past times. This could lead to unexpected behavior where emails are sent immediately when they were intended for future delivery.


Add validation to ensure scheduled times are in the future:

```diff
 if msg.ScheduledAt != "" {
   if t, err := time.Parse(time.RFC3339, msg.ScheduledAt); err != nil {
     return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid scheduledAt format"})
   } else {
     scheduledAt = t.UTC()
+    // Ensure scheduled time is in the future
+    if scheduledAt.Before(time.Now().UTC()) {
+      return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Scheduled time must be in the future"})
+    }
   }
 }
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
	for _, msg := range reqBody.Messages {
		scheduledAt := time.Now().UTC()
		if msg.ScheduledAt != "" {
			if t, err := time.Parse(time.RFC3339, msg.ScheduledAt); err != nil {
				return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid scheduledAt format"})
			} else {
				scheduledAt = t.UTC()
				// Ensure scheduled time is in the future
				if scheduledAt.Before(time.Now().UTC()) {
					return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Scheduled time must be in the future"})
				}
			}
		}
		// ... remainder of loop ...
	}
`````

</details>

<!-- suggestion_end -->

<details>
<summary>🤖 Prompt for AI Agents (early access)</summary>

```
In aws-ses-sender/api/handler.go around lines 41 to 49, the code parses the
scheduledAt timestamp but does not check if it is a future time. Add a
validation step after parsing scheduledAt to compare it with the current time
(time.Now().UTC()) and if scheduledAt is not in the future, return a bad request
error indicating the scheduled time must be in the future. This ensures emails
are only scheduled for future delivery.
```

</details>

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-05-08T03:00:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,302 @@
+package api
+
+import (
+	"aws-ses-sender/config"
+	"aws-ses-sender/model"
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"image"
+	"image/color"
+	"image/png"
+	"log"
+	"net/mail"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/gofiber/fiber/v3"
+)
+
+// createMessageHandler Message Handler
+// Handler that receives email sending requests
+func createMessageHandler(c fiber.Ctx) error {
+	start := time.Now()
+	var reqBody struct {
+		Messages []struct {
+			TopicId     string   `json:"topicId"`
+			Emails      []string `json:"emails"`
+			Subject     string   `json:"subject"`
+			Content     string   `json:"content"`
+			ScheduledAt string   `json:"scheduledAt"`
+		} `json:"messages"`
+	}
+	if err := c.Bind().JSON(&reqBody); err != nil {
+		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
+	}
+
+	db := config.GetDB()
+	reqs := make([]*model.Request, 0)
+	totCnt := 0
+	for _, msg := range reqBody.Messages {
+		scheduledAt := time.Now().UTC()
+		if msg.ScheduledAt != "" {
+			if t, err := time.Parse(time.RFC3339, msg.ScheduledAt); err != nil {
+				return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid scheduledAt format"})
+			} else {
+				scheduledAt = t.UTC()
+			}
+		}
+		for _, email := range msg.Emails {
+			if _, err := mail.ParseAddress(email); err != nil {
+				return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": fmt.Sprintf("invalid email: %s", email)})
+			}
+			req := &model.Request{
+				TopicId:     msg.TopicId,
+				To:          email,
+				Subject:     msg.Subject,
+				Content:     msg.Content,
+				ScheduledAt: &scheduledAt,
+				Status:      model.EmailMessageStatusCreated,
+			}
+			reqs = append(reqs, req)
+			totCnt++
+		}
path: aws-ses-sender/api/handler.go
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Validate essential message fields.**

The code validates email addresses but not other essential fields like subject and content. Empty fields could lead to invalid emails being sent.


Add validation for required fields:

```diff
+ // Validate essential message fields outside the email loop
+ if strings.TrimSpace(msg.Subject) == "" {
+   return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Subject cannot be empty"})
+ }
+ if strings.TrimSpace(msg.Content) == "" {
+   return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Content cannot be empty"})
+ }

 for _, email := range msg.Emails {
   if _, err := mail.ParseAddress(email); err != nil {
     return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": fmt.Sprintf("invalid email: %s", email)})
   }
   // Rest of the code...
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
    // Validate essential message fields outside the email loop
    if strings.TrimSpace(msg.Subject) == "" {
        return c.Status(fiber.StatusBadRequest).
            JSON(fiber.Map{"error": "Subject cannot be empty"})
    }
    if strings.TrimSpace(msg.Content) == "" {
        return c.Status(fiber.StatusBadRequest).
            JSON(fiber.Map{"error": "Content cannot be empty"})
    }

    for _, email := range msg.Emails {
        if _, err := mail.ParseAddress(email); err != nil {
            return c.Status(fiber.StatusBadRequest).
                JSON(fiber.Map{"error": fmt.Sprintf("invalid email: %s", email)})
        }
        req := &model.Request{
            TopicId:     msg.TopicId,
            To:          email,
            Subject:     msg.Subject,
            Content:     msg.Content,
            ScheduledAt: &scheduledAt,
            Status:      model.EmailMessageStatusCreated,
        }
        reqs = append(reqs, req)
        totCnt++
    }
`````

</details>

<!-- suggestion_end -->

<details>
<summary>🤖 Prompt for AI Agents (early access)</summary>

```
In aws-ses-sender/api/handler.go around lines 50 to 64, the code currently
validates email addresses but does not check if essential fields like subject
and content are empty. Add validation before the loop to ensure msg.Subject and
msg.Content are not empty, returning a bad request status with an appropriate
error message if they are missing. This prevents sending emails with incomplete
or invalid data.
```

</details>

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-05-08T03:00:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,302 @@
+package api
+
+import (
+	"aws-ses-sender/config"
+	"aws-ses-sender/model"
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"image"
+	"image/color"
+	"image/png"
+	"log"
+	"net/mail"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/gofiber/fiber/v3"
+)
+
+// createMessageHandler Message Handler
+// Handler that receives email sending requests
+func createMessageHandler(c fiber.Ctx) error {
+	start := time.Now()
+	var reqBody struct {
+		Messages []struct {
+			TopicId     string   `json:"topicId"`
+			Emails      []string `json:"emails"`
+			Subject     string   `json:"subject"`
+			Content     string   `json:"content"`
+			ScheduledAt string   `json:"scheduledAt"`
+		} `json:"messages"`
+	}
+	if err := c.Bind().JSON(&reqBody); err != nil {
+		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
+	}
+
+	db := config.GetDB()
+	reqs := make([]*model.Request, 0)
+	totCnt := 0
+	for _, msg := range reqBody.Messages {
+		scheduledAt := time.Now().UTC()
+		if msg.ScheduledAt != "" {
+			if t, err := time.Parse(time.RFC3339, msg.ScheduledAt); err != nil {
+				return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid scheduledAt format"})
+			} else {
+				scheduledAt = t.UTC()
+			}
+		}
+		for _, email := range msg.Emails {
+			if _, err := mail.ParseAddress(email); err != nil {
+				return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": fmt.Sprintf("invalid email: %s", email)})
+			}
+			req := &model.Request{
+				TopicId:     msg.TopicId,
+				To:          email,
+				Subject:     msg.Subject,
+				Content:     msg.Content,
+				ScheduledAt: &scheduledAt,
+				Status:      model.EmailMessageStatusCreated,
+			}
+			reqs = append(reqs, req)
+			totCnt++
+		}
+	}
+
+	chunkSize := 1000
+	for i := 0; i < len(reqs); i += chunkSize {
+		end := i + chunkSize
+		if end > len(reqs) {
+			end = len(reqs)
+		}
+		if err := db.Create(reqs[i:end]).Error; err != nil {
+			return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+		}
+	}
+
+	// Return the result
+	return c.JSON(fiber.Map{
+		"count":   totCnt,
+		"elapsed": time.Since(start).String(),
+	})
+}
+
+// createOpenEventHandler Open Event Handler
+// Attach an image script to the email and assume it has been read when the image is accessed
+func createOpenEventHandler(c fiber.Ctx) error {
+	reqId := c.Query("requestId")
+	if reqId != "" {
+		// Consider email as opened and create data
+		db := config.GetDB()
+		if reqIdInt, err := strconv.Atoi(reqId); err == nil {
+			if err := db.Create(&model.Result{
+				RequestId: uint(reqIdInt),
+				Status:    "Open",
+				Raw:       "{}",
+			}).Error; err != nil {
+				log.Printf("Failed to create open event: %s", err.Error())
+			}
+		} else {
+			log.Printf("Invalid requestId: %s", reqId)
+		}
+	}
+
+	// Return a blank image
+	img := image.NewRGBA(image.Rect(0, 0, 1, 1))
+	img.Set(0, 0, color.RGBA{R: 0, G: 0, B: 0, A: 0})
+	var buf bytes.Buffer
+	if err := png.Encode(&buf, img); err != nil {
+		return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+	}
+	c.Set("Content-Type", "image/png")
+	return c.Send(buf.Bytes())
+}
+
+// createResultEventHandler Result Event Handler
+// Handler that receives AWS SES results
+func createResultEventHandler(c fiber.Ctx) error {
+	msgType := c.Get("x-amz-sns-message-type")
+	if msgType != "Notification" && msgType != "SubscriptionConfirmation" {
+		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid SNS Message Type"})
+	}
+
+	var reqBody struct {
+		Type         string `json:"Type"`
+		Message      string `json:"Message"`
+		MessageId    string `json:"MessageId"`
+		SubscribeURL string `json:"SubscribeURL"`
+	}
+	if err := c.Bind().JSON(&reqBody); err != nil {
+		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Failed to parse SNS message"})
+	}
+
+	if reqBody.Type == "SubscriptionConfirmation" {
+		// Subscription confirmation
+		log.Printf("Subscription confirmation required. Visiting: %s", reqBody.SubscribeURL)
+		return c.JSON(fiber.Map{"message": "Subscription confirmation required"})
+	}
+
+	if reqBody.Type != "Notification" {
+		return c.JSON(fiber.Map{"message": "Other message type received"})
+	}
+
+	var sesNotification struct {
+		NotificationType string `json:"notificationType"`
+		Mail             struct {
+			MessageId string `json:"messageId"`
+			Headers   []struct {
+				Name  string `json:"name"`
+				Value string `json:"value"`
+			} `json:"headers"`
+		} `json:"mail"`
+	}
+	if err := json.Unmarshal([]byte(reqBody.Message), &sesNotification); err != nil {
+		return c.JSON(fiber.Map{"message": fmt.Sprintf("Non-SES notification received: %v", err)})
+	}
+	if sesNotification.Mail.MessageId == "" {
+		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "SES message_id not found"})
+	}
path: aws-ses-sender/api/handler.go
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Enhance SES notification validation and type handling.**

The code only validates the MessageId field of the SES notification, which may not be sufficient to handle all types of notifications correctly.


Improve the validation and add specific handling for different notification types:

```diff
 var sesNotification struct {
   NotificationType string `json:"notificationType"`
+  EventType        string `json:"eventType"`        // For some notification types
   Mail             struct {
     MessageId string `json:"messageId"`
     Headers   []struct {
       Name  string `json:"name"`
       Value string `json:"value"`
     } `json:"headers"`
   } `json:"mail"`
+  // Add more specific fields for different notification types
+  Bounce     *json.RawMessage `json:"bounce,omitempty"`
+  Complaint  *json.RawMessage `json:"complaint,omitempty"`
+  Delivery   *json.RawMessage `json:"delivery,omitempty"`
 }

 if err := json.Unmarshal([]byte(reqBody.Message), &sesNotification); err != nil {
   return c.JSON(fiber.Map{"message": fmt.Sprintf("Non-SES notification received: %v", err)})
 }
 if sesNotification.Mail.MessageId == "" {
   return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "SES message_id not found"})
 }
+ 
+ // Log notification type for monitoring
+ log.Printf("Received %s notification from SES for message %s", 
+           sesNotification.NotificationType, sesNotification.Mail.MessageId)
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
        var sesNotification struct {
            NotificationType string           `json:"notificationType"`
            EventType        string           `json:"eventType"`        // For some notification types
            Mail             struct {
                MessageId string `json:"messageId"`
                Headers   []struct {
                    Name  string `json:"name"`
                    Value string `json:"value"`
                } `json:"headers"`
            } `json:"mail"`
            // Add more specific fields for different notification types
            Bounce     *json.RawMessage `json:"bounce,omitempty"`
            Complaint  *json.RawMessage `json:"complaint,omitempty"`
            Delivery   *json.RawMessage `json:"delivery,omitempty"`
        }
        if err := json.Unmarshal([]byte(reqBody.Message), &sesNotification); err != nil {
            return c.JSON(fiber.Map{"message": fmt.Sprintf("Non-SES notification received: %v", err)})
        }
        if sesNotification.Mail.MessageId == "" {
            return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "SES message_id not found"})
        }

        // Log notification type for monitoring
        log.Printf("Received %s notification from SES for message %s",
                   sesNotification.NotificationType, sesNotification.Mail.MessageId)
`````

</details>

<!-- suggestion_end -->

<details>
<summary>🤖 Prompt for AI Agents (early access)</summary>

```
In aws-ses-sender/api/handler.go around lines 144 to 159, the current code only
checks if the SES notification's MessageId is present, which is insufficient for
robust validation. Enhance the validation by also verifying the NotificationType
field to ensure it matches expected SES notification types. Add conditional
handling logic based on the NotificationType to process different notification
types appropriately, improving the overall correctness and resilience of the SES
notification processing.
```

</details>

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-05-08T03:00:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,302 @@
+package api
+
+import (
+	"aws-ses-sender/config"
+	"aws-ses-sender/model"
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"image"
+	"image/color"
+	"image/png"
+	"log"
+	"net/mail"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/gofiber/fiber/v3"
+)
+
+// createMessageHandler Message Handler
+// Handler that receives email sending requests
+func createMessageHandler(c fiber.Ctx) error {
+	start := time.Now()
+	var reqBody struct {
+		Messages []struct {
+			TopicId     string   `json:"topicId"`
+			Emails      []string `json:"emails"`
+			Subject     string   `json:"subject"`
+			Content     string   `json:"content"`
+			ScheduledAt string   `json:"scheduledAt"`
+		} `json:"messages"`
+	}
+	if err := c.Bind().JSON(&reqBody); err != nil {
+		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
+	}
+
+	db := config.GetDB()
+	reqs := make([]*model.Request, 0)
+	totCnt := 0
+	for _, msg := range reqBody.Messages {
+		scheduledAt := time.Now().UTC()
+		if msg.ScheduledAt != "" {
+			if t, err := time.Parse(time.RFC3339, msg.ScheduledAt); err != nil {
+				return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid scheduledAt format"})
+			} else {
+				scheduledAt = t.UTC()
+			}
+		}
+		for _, email := range msg.Emails {
+			if _, err := mail.ParseAddress(email); err != nil {
+				return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": fmt.Sprintf("invalid email: %s", email)})
+			}
+			req := &model.Request{
+				TopicId:     msg.TopicId,
+				To:          email,
+				Subject:     msg.Subject,
+				Content:     msg.Content,
+				ScheduledAt: &scheduledAt,
+				Status:      model.EmailMessageStatusCreated,
+			}
+			reqs = append(reqs, req)
+			totCnt++
+		}
+	}
+
+	chunkSize := 1000
+	for i := 0; i < len(reqs); i += chunkSize {
+		end := i + chunkSize
+		if end > len(reqs) {
+			end = len(reqs)
+		}
+		if err := db.Create(reqs[i:end]).Error; err != nil {
+			return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+		}
+	}
+
+	// Return the result
+	return c.JSON(fiber.Map{
+		"count":   totCnt,
+		"elapsed": time.Since(start).String(),
+	})
+}
+
+// createOpenEventHandler Open Event Handler
+// Attach an image script to the email and assume it has been read when the image is accessed
+func createOpenEventHandler(c fiber.Ctx) error {
+	reqId := c.Query("requestId")
+	if reqId != "" {
+		// Consider email as opened and create data
+		db := config.GetDB()
+		if reqIdInt, err := strconv.Atoi(reqId); err == nil {
+			if err := db.Create(&model.Result{
+				RequestId: uint(reqIdInt),
+				Status:    "Open",
+				Raw:       "{}",
+			}).Error; err != nil {
+				log.Printf("Failed to create open event: %s", err.Error())
+			}
+		} else {
+			log.Printf("Invalid requestId: %s", reqId)
+		}
+	}
+
+	// Return a blank image
+	img := image.NewRGBA(image.Rect(0, 0, 1, 1))
+	img.Set(0, 0, color.RGBA{R: 0, G: 0, B: 0, A: 0})
+	var buf bytes.Buffer
+	if err := png.Encode(&buf, img); err != nil {
+		return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+	}
+	c.Set("Content-Type", "image/png")
+	return c.Send(buf.Bytes())
+}
path: aws-ses-sender/api/handler.go
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Improve open tracking reliability.**

The open event tracker returns a 200 OK with a transparent PNG even when database operations fail. This could lead to lost tracking data without clear visibility into issues.


While keeping the user experience smooth, improve the logging and consider implementing a background task for persistence failures:

```diff
 if reqIdInt, err := strconv.Atoi(reqId); err == nil {
   if err := db.Create(&model.Result{
     RequestId: uint(reqIdInt),
     Status:    "Open",
     Raw:       "{}",
   }).Error; err != nil {
     log.Printf("Failed to create open event: %s", err.Error())
+    // Queue the event for retry in a background worker
+    // This is pseudo-code and would need to be implemented
+    go func(reqID uint) {
+      // Add retry logic with backoff
+      retryOpenEventTracking(reqID)
+    }(uint(reqIdInt))
   }
 } else {
   log.Printf("Invalid requestId: %s", reqId)
 }
```

Also consider adding request logging with metadata:

```diff
+ // Log open event with additional metadata for analytics
+ userAgent := c.Get("User-Agent")
+ referer := c.Get("Referer")
+ ip := c.IP()
+ log.Printf("Email open event: requestId=%s, userAgent=%s, referer=%s, ip=%s", 
+            reqId, userAgent, referer, ip)
```


> Committable suggestion skipped: line range outside the PR's diff.

<details>
<summary>🤖 Prompt for AI Agents (early access)</summary>

```
In aws-ses-sender/api/handler.go around lines 87 to 114, the open event handler
currently returns a 200 OK with a transparent PNG even if database operations
fail, which risks losing tracking data silently. To fix this, enhance logging by
adding detailed error logs including request metadata when database creation
fails or requestId is invalid. Additionally, implement a background retry
mechanism or task queue to handle persistence failures asynchronously without
impacting the user experience. Finally, add request logging that captures
relevant metadata such as requestId and client info for better observability.
```

</details>

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-05-08T03:00:07Z
author_association: CONTRIBUTOR

PR comments:
user: login: welcome[bot]
created_at: 2025-05-07T04:51:19Z
body: Thanks for opening this pull request! 🎉 Please check out our contributing guidelines.

author_association: NONE
user: login: coderabbitai[bot]
created_at: 2025-05-07T04:51:24Z
body: <!-- This is an auto-generated comment: summarize by coderabbit.ai -->
<!-- walkthrough_start -->

## Walkthrough

This change introduces a new AWS SES-based email sender service written in Go, structured as a standalone module within the repository. It includes complete documentation, a Dockerfile, environment and database configuration, models for email requests and results, API endpoints using the Fiber framework, background scheduler and sender processes, and an AWS SES integration. The service supports email scheduling, status tracking, analytics, and open/result event handling.

## Changes

| File(s)                                                                                      | Change Summary                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
|---------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| README.md                                                                                   | Added a new entry in the Table of Contents for "AWS SES Email Sender" linking to the new recipe directory.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| aws-ses-sender/README.md                                                                    | Introduced comprehensive documentation for the new AWS SES sender service, including features, architecture, requirements, setup, API endpoints, and usage instructions.                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| aws-ses-sender/.gitignore                                                                   | Added to ignore `.idea` and `.env` files in the new service directory.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| aws-ses-sender/Dockerfile                                                                   | Added Dockerfile for containerizing the new Go-based AWS SES sender application.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| aws-ses-sender/main.go                                                                      | New application entry point: initializes Sentry, starts scheduler and sender goroutines, and runs the HTTP server.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| aws-ses-sender/api/handler.go                                                               | Implements HTTP handlers for message creation, open event tracking, result event processing, result count retrieval, and sent count retrieval.                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| aws-ses-sender/api/middlewares.go                                                           | Added API key authentication middleware for protecting certain API endpoints.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| aws-ses-sender/api/route.go                                                                 | Registers API routes for the above handlers under `/v1` using Fiber, applying middleware selectively.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| aws-ses-sender/api/server.go                                                                | Sets up and runs the Fiber web server with middleware and logging, using the defined routes.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| aws-ses-sender/cmd/scheduler.go                                                            | Implements a scheduler to select and queue email requests for sending, rate-limited per configuration.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| aws-ses-sender/cmd/sender.go                                                               | Implements the sender that processes queued requests, sends emails via AWS SES, and updates request status/results.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| aws-ses-sender/config/db.go                                                                | Provides a singleton GORM-based PostgreSQL connection manager, reading configuration from environment variables.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| aws-ses-sender/config/env.go                                                               | Loads environment variables from `.env` (if present) and provides a thread-safe accessor with default value support.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| aws-ses-sender/model/email.go                                                              | Defines GORM models for email requests and results, with status constants and automatic migration on startup.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| aws-ses-sender/pkg/aws/ses.go                                                              | Implements an AWS SES client wrapper for sending emails, including custom header support for request tracking.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |

## Sequence Diagram(s)

```mermaid
sequenceDiagram
    participant Client
    participant API_Server
    participant Scheduler
    participant Sender
    participant AWS_SES
    participant Database

    Client->>API_Server: POST /v1/messages (create email requests)
    API_Server->>Database: Insert email requests
    API_Server-->>Client: Response (count, elapsed)

    loop Every minute
        Scheduler->>Database: Select & update eligible requests (status: created -> processing)
        Scheduler->>Sender: Send batch of requests via channel
    end

    Sender->>AWS_SES: SendEmail (with tracking header)
    AWS_SES-->>Sender: Response (messageId or error)
    Sender->>Database: Update request status, store messageId/error

    Client->>API_Server: GET /v1/events/open?requestId=...
    API_Server->>Database: Record open event
    API_Server-->>Client: 1x1 PNG

    AWS_SES-->>API_Server: POST /v1/events/results (SNS notification)
    API_Server->>Database: Store result event

    Client->>API_Server: GET /v1/topics/:topicId
    API_Server->>Database: Query request and result counts
    API_Server-->>Client: JSON counts
```

## Suggested reviewers

- ReneWerner87

## Poem

> In fields of code where fibers grow,  
> A rabbit hops, with emails in tow.  
> With SES wings and schedule bright,  
> It sends your words through cloud and night.  
> Tracking opens, counting sends,  
> New features here—our hop never ends!  
> 🐇📧✨

<!-- walkthrough_end -->

<!-- announcements_start -->

> [!TIP]
> <details>
> <summary>⚡️ Faster reviews with caching</summary>
> 
> - CodeRabbit now supports caching for code and dependencies, helping speed up reviews. This means quicker feedback, reduced wait times, and a smoother review experience overall. Cached data is encrypted and stored securely. This feature will be automatically enabled for all accounts on May 16th. To opt out, configure `Review - Disable Cache` at either the organization or repository level. If you prefer to disable all data retention across your organization, simply turn off the `Data Retention` setting under your Organization Settings.
> 
> Enjoy the performance boost—your workflow just got faster.
> 
> </details>

<!-- announcements_end -->
<!-- internal state start -->


<!-- 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 -->

<!-- internal state end -->
<!-- finishing_touch_checkbox_start -->

<details open="true">
<summary>✨ Finishing Touches</summary>

- [ ] <!-- {"checkboxId": "7962f53c-55bc-4827-bfbf-6a18da830691"} --> 📝 Generate Docstrings

</details>

<!-- finishing_touch_checkbox_end -->
<!-- tips_start -->

---



<details>
<summary>🪧 Tips</summary>

### Chat

There are 3 ways to chat with [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=gofiber/recipes&utm_content=3048):

- Review comments: Directly reply to a review comment made by CodeRabbit. Example:
  - `I pushed a fix in commit <commit_id>, please review it.`
  - `Generate unit testing code for this file.`
  - `Open a follow-up GitHub issue for this discussion.`
- Files and specific lines of code (under the "Files changed" tab): Tag `@coderabbitai` in a new review comment at the desired location with your query. Examples:
  - `@coderabbitai generate unit testing code for this file.`
  -	`@coderabbitai modularize this function.`
- PR comments: Tag `@coderabbitai` in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
  - `@coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.`
  - `@coderabbitai read src/utils.ts and generate unit testing code.`
  - `@coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.`
  - `@coderabbitai help me debug CodeRabbit configuration file.`

### Support

Need help? Create a ticket on our [support page](https://www.coderabbit.ai/contact-us/support) for assistance with any issues or questions.

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

### CodeRabbit Commands (Invoked using PR comments)

- `@coderabbitai pause` to pause the reviews on a PR.
- `@coderabbitai resume` to resume the paused reviews.
- `@coderabbitai review` to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
- `@coderabbitai full review` to do a full review from scratch and review all the files again.
- `@coderabbitai summary` to regenerate the summary of the PR.
- `@coderabbitai generate docstrings` to [generate docstrings](https://docs.coderabbit.ai/finishing-touches/docstrings) for this PR.
- `@coderabbitai generate sequence diagram` to generate a sequence diagram of the changes in this PR.
- `@coderabbitai resolve` resolve all the CodeRabbit review comments.
- `@coderabbitai configuration` to show the current CodeRabbit configuration for the repository.
- `@coderabbitai help` to get help.

### Other keywords and placeholders

- Add `@coderabbitai ignore` anywhere in the PR description to prevent this PR from being reviewed.
- Add `@coderabbitai summary` to generate the high-level summary at a specific location in the PR description.
- Add `@coderabbitai` or `@coderabbitai title` anywhere in the PR title to generate the title automatically.

### CodeRabbit Configuration File (`.coderabbit.yaml`)

- You can programmatically configure CodeRabbit by adding a `.coderabbit.yaml` file to the root of your repository.
- Please see the [configuration documentation](https://docs.coderabbit.ai/guides/configure-coderabbit) for more information.
- If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: `# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json`

### Documentation and Community

- Visit our [Documentation](https://docs.coderabbit.ai) for detailed information on how to use CodeRabbit.
- Join our [Discord Community](http://discord.gg/coderabbit) to get help, request features, and share feedback.
- Follow us on [X/Twitter](https://twitter.com/coderabbitai) for updates and announcements.

</details>

<!-- tips_end -->
author_association: CONTRIBUTOR
user: login: welcome[bot]
created_at: 2025-05-12T06:34:33Z
body: Congrats on merging your first pull request! 🎉 We here at Fiber are proud of you!

author_association: NONE