Skip to main content

Command Palette

Search for a command to run...

Email 2FA: When My OTP Emails Refused to Send (Go + RabbitMQ + Elastic Email)

Published
4 min read

Context:

Building the Email 2FA (Two-Factor Authentication) feature for my backend.

Stack: Go, GORM, RabbitMQ, Elastic Email.

This is me, understanding how everything connects. This is not a tutorial, just real notes from debugging and clarity.

Starting Point

I wanted users to get an OTP (One-Time Password) via email for 2FA.

Simple idea: user requests → generate code → send email to user’s email address.

In reality, it went more like this:

User clicks "Send OTP" → Client
Backend generates OTP → Code logic
Producer - Publishes message to RabbitMQ → Queue
Consumer picks it up → Message picker
Delivers email via Elastic Email (SMTP)

And somewhere in that chain, something always went wrong.

Error 1: “relation ‘otp_verifications’ does not exist”

This one was simple in hindsight:

GORM tried to insert into a table that didn’t exist yet.

I had created this OtpVerification model below, but didn’t migrate it to my database.

type OtpVerification struct {
    ID        uuid.UUID `gorm:"primarykey"`
    UserID    uuid.UUID
    OtpCode   string
    ExpiresAt time.Time
    Verified  bool
    CreatedAt time.Time
}

The Fix

The fix was just to have it running in the database: db.AutoMigrate(&models.OtpVerification{}).

Lesson: GORM doesn’t magically create tables; migration is my responsibility, geez!!.

Error 2: “Unknown task type from email_otp_queue”

I am using RabbitMQ to send tasks to a background worker.

I wanted to publish a message to a queue and have another process handle the actual email sending.

Producer = the Go service that publishes the task in connection with the RabbitMQ (queue)

Consumer = the background worker that reads and processes the task for delivery.

My mistake: when I published the OTP message, I didn’t add targeted user’s email address and specify the type properly.

Here’s the code that was broken:

utils.PublishMessage(
    os.Getenv("EMAIL_OTP_QUEUE"),
    otp.UserID.String(), // userID instead of email
    generateOtp,
    "",                  // empty description
    "",
    user.Email,
    "",
)

The consumer was expecting a "Type": "otp" message to know which handler to call.

But I sent an empty string. So RabbitMQ successfully added it to the queue, but the consumer didn’t know what to do because of “unknown task type.” This mistake was an oversight; I copied the code block from a different feature and forgot to modify it.

The Fix

After understanding what was happening, I updated it:

utils.PublishMessage(
    os.Getenv("EMAIL_OTP_QUEUE"),
    user.Email,       // email
    generateOtp,
    "otp",            // OTP
    "",
    "",
    "",
)

Now the logs looked like this:

Published task to email_otp_queue for oyi.....@gmail.com
Sent otp email to oyi.....@gmail.com

Finally sent the OTP to my inbox, and yeah, I got it.

Lesson:

  • Always review and verify copied code blocks

Error 3: “535 Authentication failed: Access denied”

This one was from Elastic Email (deliverer). I had changed my account email to oyi.....@gmail.com, but my SMTP username was still the old aod.....@gmail.com. Elastic Email SMTP credentials are account-level, not tied to the sender identity only.

Even though I verified the new email, the actual SMTP authentication was still using the old one.

The dashboard said:

“For testing purposes, you can only send to your registered email address.”

I knew about Elastic Email restrictions on free accounts to send only to the verified sender address for testing, which I had done.

The Fix

I created a new SMTP using the new, active (verified) account, deleted the old one, and it worked, I got the OTP email.

Lessons:

  • You can verify multiple “from” addresses on Elastic Email

  • But your SMTP username remains the one you registered with (main issue)

  • If you change your login email, you may need a new API key

How It All Connects (my mental model now)

User → /request-otp → Service layer → PublishMessage()
       → RabbitMQ queue → EmailConsumer → SendMail() → Elastic Email → Inbox

Each layer has its responsibility:

  • Service layer - business logic (create OTP, store it in the db)

  • Publisher - posts a task to RabbitMQ i.e queue

  • Consumer - listens, reads the message, and executes email sending

  • Elastic Email (SMTP) - actually delivers the email to the user

RabbitMQ is just the middleman, like a post office between the app and the worker that sends the mail. Both the publisher (producer) and consumer are connected to the RabbitMQ

Key Lessons

  1. Every queue message must match what the consumer expects, fields, types, and purpose.

  2. SMTP credentials are tied to your sending account, not your email variable.

  3. RabbitMQ doesn’t execute, it only transfers.

  4. Always test both ends: producer and consumer.

  5. Logs are your ally, Oyindamola, read them slowly.

  6. The real “fix” is understanding the flow, not just changing code.

Clarity: The architecture makes sense now. It taught me how background queues actually work, how they all connect to achieve the end goal.