DEV Community

Bagas Rayhan
Bagas Rayhan

Posted on

2

System Design for a Simple and Effective Email Notification Delivery API

Email is one of the media that is often used to send notifications to end-users. Knowing the system architecture used to send email notifications is very important so that it can run well and quickly. One system architecture scheme that can be used is a worker pool, so that the API can respond to requests quickly while the task of sending emails is done concurrently.

Get to know the Worker Pool

In a worker pool, incoming tasks are added to the queue, while being worked on by a concurrent pool of workers.
We can analogize it to sending a package to a logistics service. First, the package is registered and weighed to get an air bill. Then the package that has been given an index will be put into the queue of goods while waiting to be sent to the destination by the courier.

Here is a picture of the workflow of the worker pool:

Image description

Techstack

Techstack is an important tool in building systems. Here is the techstack that will be used:

  • Golang 1.22 version: can use higher or lower version
  • smtp gmail: a free smtp server provided for every gmail account
  • Postman: client to test our API

Guide to Setup Gmail Smtp

  1. Visit https://myaccount.google.com/ don't forget to login with your account
  2. Visit the 2-step verification page. It may be called 'Two Step Authentication' in English.

Image description

  1. Enter your google account credentials. Then enable two factor authentication.

Image description

  1. Scroll down and then Go to the Application Password page. In English it may be called 'Application Password'.

Image description

  1. Enter the application name and copy the application password, then paste it into the .env file.

Image description


Let's Get Started on the Project

  1. Project structure

Image description

Explanation:

  • main.go: starting point to start the application
  • .env: file for storing environment data such as credentials. Store the application password in this file.
  • .gitignore: To prevent commits to important files such as .env. Note: the .env file should not be committed as it stores important credentials.
  • /mail: folder or package for sending emails via smtp
  • /jobs: package for creating worker pools
  • 2. Let's Start Coding a) Project initialization
  • Perform project declaration Type: go mod init <project-name>
  • download the required dependencies. type this command on cmd
go get github.com/gin-gonic/gin, github.com/joho/godotenv
Enter fullscreen mode Exit fullscreen mode

Notes: For the gin package, you can use other web framework packages such as Fiber, or the native net/http package.
b) Create a Dto package to standardize the request format

package dto

type Body struct {
    Sender   string `json:"sender" binding:"required"`
    Receiver string `json:"receiver" binding:"required"`
    Subject  string `json:"subject" binding:"required"`
    Message  string `json:"message"`
}

type Response struct {
    Message string                 `json:"message"`
    Data    map[string]interface{} `json:"data"`
}
Enter fullscreen mode Exit fullscreen mode

c) Create Package mail to send emails.

package mail

import (
    "crypto/tls"
    "fmt"
    "go-notify/dto"
    "net/smtp"
    "os"
)

const port = "587" //25 or 587
const smtpDomain = "smtp.gmail.com"

var M *Mail

type Mail struct {
    Auth     smtp.Auth
    hostname string
    From     string
}

func NewMail() *Mail {
    return &Mail{
        hostname: smtpDomain,
    }
}

func (m *Mail) SetAuth() {
    username := os.Getenv("GOOGLE_MAIL")
    pass := os.Getenv("GOOGLE_APP_PASSWORD")
    m.From = username
    m.Auth = smtp.PlainAuth("", username, pass, m.hostname)
}

func (m *Mail) SendMail(content dto.Body) error {
    addr := m.hostname + ":" + port
    // Create a TLS configuration
    tlsConfig := &tls.Config{
        InsecureSkipVerify: false, // Set to true only if needed for testing
        ServerName:         "smtp.gmail.com",
    }
    // client instance
    client, err := smtp.Dial(addr)
    if err != nil {
        return err
    }
    defer client.Close()
    // Initiate TLS handshake
    if err = client.StartTLS(tlsConfig); err != nil {
        return err
    }
    // Authenticate with the server
    if err = client.Auth(m.Auth); err != nil {
        return ErrAuth
    }
    // Set the sender and recipient
    // if err = client.Mail(m.From); err != nil {
    //  return ErrSender
    // }
    if err = client.Mail(content.Sender); err != nil {
        return ErrSender
    }
    if err = client.Rcpt(content.Receiver); err != nil {
        return ErrRecipient
    }
    // Write the email body
    writer, err := client.Data()
    if err != nil {
        return err
    }
    _, err = fmt.Fprintf(writer, "Subject: %s\r\n\r\n %s.", content.Subject, content.Message)
    if err != nil {
        return err
    }
    err = writer.Close()
    if err != nil {
        return err
    }
    // Send the QUIT command and close the connection
    if err = client.Quit(); err != nil {
        return err
    }
    return nil
}
Enter fullscreen mode Exit fullscreen mode

d) Create package jobs for the workerpool

package jobs

import (
    "go-notify/dto"
    "go-notify/mail"
    "log"
    "sync"
)

type queue struct {
    Body  dto.Body
    retry uint
}

type WorkerPool struct {
    Queue  chan queue
    Worker uint
}

func NewWorkerPool(worker uint, size int) *WorkerPool {
    return &WorkerPool{
        Queue:  make(chan queue, size),
        Worker: worker,
    }
}

func (wp *WorkerPool) Add(request dto.Body, rty uint) {
    q := queue{
        retry: rty,
        Body:  request,
    }
    wp.Queue <- q
}

func (wp *WorkerPool) Work(wg *sync.WaitGroup) {
    defer wg.Done()
    log.Println("initializing worker")
    for job := range wp.Queue {
        log.Println("processing job ", job.Body.Receiver)

        err := mail.M.SendMail(job.Body)
        if err != nil {
            log.Println(err)
            // if sender or recepient not found, omit proccess and continue to next queue
            if err == mail.ErrRecipient || err == mail.ErrSender {
                continue
            }
            // retry
            // pass proccess and continue to next queue
            if job.retry > 3 {
                log.Println("this job has been retried more than 3 times")
                continue
            }
            job.retry++
            wp.Add(job.Body, job.retry)
            continue
        }
        log.Println("success send mail to: ", job.Body.Receiver)
    }
}

func (wp *WorkerPool) Do(wg *sync.WaitGroup) {
    for i := 0; i < int(wp.Worker); i++ {
        log.Println("creating worker: ", i+1)
        go wp.Work(wg)
    }
}

Enter fullscreen mode Exit fullscreen mode

e) Create router, controller, and package initialization in main.go

package main

import (
    "go-notify/dto"
    "go-notify/jobs"
    "go-notify/mail"
    "log"
    "net/http"
    "sync"

    "github.com/gin-gonic/gin"

    "github.com/joho/godotenv"
    // _ "github.com/joho/godotenv"
)

func main() {
    var err error
    err = godotenv.Load(".env")
    if err != nil {
        panic(err)
    }

    mail.M = mail.NewMail()
    mail.M.SetAuth()

    var wg = &sync.WaitGroup{}
    // You can use other settings for the number of workers and queues.
    // Experiment and adjust to your device
    wp := jobs.NewWorkerPool(5, 25)

    wg.Add(int(wp.Worker))

    wp.Do(wg)

    r := gin.Default()
    r.POST("/api/mail", func(ctx *gin.Context) {
        var req dto.Body
        if err := ctx.ShouldBindJSON(&req); err != nil {
            ctx.JSON(http.StatusBadRequest, dto.Response{
                Message: "error while binding body into struct",
                Data: map[string]interface{}{
                    "error": err.Error(),
                },
            })
            return
        }
        log.Printf("binding sender:%s receiver:%s\n", req.Sender, req.Receiver)
        wp.Add(req, 0)
        ctx.JSON(200, gin.H{
            "message": "ok",
        })
    })

    r.POST("/api/mail/v2", func(ctx *gin.Context) {
        var req dto.Body
        if err := ctx.ShouldBindJSON(&req); err != nil {
            ctx.JSON(http.StatusBadRequest, dto.Response{
                Message: "error while binding body into struct",
                Data: map[string]interface{}{
                    "error": err.Error(),
                },
            })
            return
        }
        err = mail.M.SendMail(req)
        if err != nil {
            ctx.JSON(http.StatusBadRequest, dto.Response{
                Message: "failed to send email",
                Data: map[string]interface{}{
                    "error": err.Error(),
                },
            })
            return
        }
        ctx.JSON(200, gin.H{
            "message": "ok",
        })
    })

    r.Run(":8080")
    wg.Wait()
}
Enter fullscreen mode Exit fullscreen mode

d) Try to run App

  • type in your cmd: go run main.go it will display the console as follows:

Image description

  • then hit the API POST http://localhost:8080/api/mail in Postman

Image description

  • Look at the console, it will show the following result:

Image description

And here is the inbox of the message sent to the recipient:

Image description

Don't forget to Follow:

Top comments (0)