Way to reduce email Bounce and Spam number for Newsletter in Laravel

Way to reduce email Bounce and Spam number for Newsletter in Laravel

Posted on:October 27, 2023 at 08:00 PM

Hello everyone

Handling email bounce and spam rate lower is really hard when you manage email newsletter or sign-up for a laravel application. Recently I faced such a challenge to reduce the bounce and spam rate lower. Believe me, it was not easy! I spend some time to do research and found a work around. Today I will show you how I tackled this. This is a findings for my workaround.

ℹ This is my honest observation. This is not a sponsored article!

Challenges

Scenario:
I have a laravel site where anyone can join for email newsletter. Since it’s open for everyone, which means visitor can able to submit the newsletter form with any random which might not exist but valid e.g. [email protected]!

What’s problem with that?:
The laravel app is using Postmark email sending service. Which means the laravel app take your email, store to DB and try to send via postmark. What will be happened here?

  • The Postmark consider this email as fake because the email is not exist.
  • As a result, it will increase the bounce rate.

If you have such kind of scenario so frequent, the email sending service will stop your ability to send email. Event it does not matter, which email service you want!

Workaround?

So, what is the solution to tackle this particular scenario? Well, there is a verification email sending bundle which is way expensive then sending regular email!

Therefore I found a workaround how to tackle this scenario which is much cheaper. Here is my plan:

  • Manage a local spammy email list in local database
  • Use a email bounce detecting service, I will use zerobounce, you can use whatever suite for you.

My idea is:

  • Receive email e.g. [email protected] for being enlisted from a visitor
  • Check in my local spammy database whether this email address is already exist?
  • Send this email address to zerobound via API to confirm the status of this email address.
    • If it is not a valid (not having a green signal), then add this email to the local spammy database, and do not send email.
    • Else, update the subscriber table with this email and send email via postmark.

Here is how I save ~70 USD based on my approach.

ServiceMinimum Spend (USD)
Mailgun~100
My Workaround~30

ℹ Disclaimer: I just use mailgun name randomly!

Write Implementations

In the implementation, I will make it two options, testing and code. You can jump into the code if you are not interested for the testing.

What I need?

⚠️ You can use any other services!

Write Test

I will write a feature test class, here is my skeleton for the feature test:


// Your imports

class NewsletterSubscriberTest extends TestCase
{
    use RefreshDatabase;

    /** @test */
    public function a_visitor_can_subscribe_for_email_newsletter_and_will_get_the_email_for_verification()
    {
    }

    /** @test */
    public function it_sends_email_notification_to_valid_email_address_verifying_by_zero_bounce_only()
    {
    }

    /**
     * @test
     * @dataProvider emailInValidationStatus
     * */
    public function it_does_not_send_notification_if_the_email_is_invalid_verified_by_zero_bounce($status)
    {
    }

    /** @test */
    public function it_adds_email_to_database_if_it_not_valid_by_zero_bounce()
    {
    }

    /** @test */
    public function it_does_not_send_validation_checking_request_if_the_email_address_found_in_the_invalid_db_already()
    {
    }

    /** It's a data provider */
    public static function emailInValidationStatus()
    {
    }

    // More test if you feel.

Here is the full implementation that I prefer to put in the collapsible section.


// Your imports

class NewsletterSubscriberTest extends TestCase
{
    use RefreshDatabase;

    /** @test */
    public function a_visitor_can_subscribe_for_email_newsletter_and_will_get_the_email_for_verification()
    {
        // Arrange
        Mail::fake();
        Queue::fake();

        Http::fake([
            'api.zerobounce.net/v2/validate*' => Http::response([
                "status" => "valid",
            ], 200)
        ]);

        // Act
        $response = $this->from(route('home'))
            ->post(route('subscribe.newsletter'), [
                'email' => '[email protected]'
            ]);

        // Assert
        $this->assertDatabaseCount('subscribers', 1);
        $this->assertDatabaseHas('subscribers', [
            'name' => 'Guest',
            'email' => '[email protected]',
        ]);

        $subscriber = Subscriber::sole();

        Queue::assertPushed(SubscriberJoin::class, function ($job) use ($subscriber) {
            return $job->subscriber->id === $subscriber->id;
        });

        // Dispatch the job
        (new SubscriberJoin($subscriber))->handle();

        Mail::assertSent(SubscriberJoined::class, function ($mail) use ($subscriber) {
            $mail->to($subscriber->email);

            // Verify your email content.

            return $mail->subscriber->id === $subscriber->id;
        });

        $response->assertStatus(302);
        $response->assertRedirect(route('home'));
    }

    /** @test */
    public function it_sends_email_notification_to_valid_email_address_verifying_by_zero_bounce_only()
    {
        // Arrange
        Queue::fake();
        $validEmail = "[email protected]";

        Http::fake([
            'api.zerobounce.net/v2/validate*' => Http::response([
                "address" => $validEmail,
                "status" => "valid",
            ], 200)
        ]);

        // Act
        $this->from(route('home'))
            ->post(route('subscribe.newsletter'), [
                'email' => $validEmail
            ]);

        $subscriber = Subscriber::sole();

        // Assert
        Queue::assertPushed(SubscriberJoin::class, function ($job) use ($subscriber) {
            return $job->subscriber->id === $subscriber->id;
        });
    }

    /**
     * @test
     * @dataProvider emailInValidationStatus
     * */
    public function it_does_not_send_notification_if_the_email_is_invalid_verified_by_zero_bounce($status)
    {
        // Arrange
        Queue::fake();
        $validEmail = "[email protected]";

        Http::fake([
            'api.zerobounce.net/v2/validate*' => Http::response([
                "address" => $validEmail,
                "status" => $status
            ], 200)
        ]);

        // Act
        $this->from(route('home'))
            ->post(route('subscribe.newsletter'), [
                'email' => $validEmail
            ]);

        // Assert
        Queue::assertNothingPushed(SubscriberJoin::class);
    }

    /** @test */
    public function it_adds_email_to_database_if_it_not_valid_by_zero_bounce()
    {
        // Arrange
        $validEmail = "[email protected]";

        Http::fake([
            'api.zerobounce.net/v2/validate*' => Http::response([
                "address" => $validEmail,
                "status" => "invalid"
            ], 200)
        ]);

        $this->assertDatabaseCount('invalid_emails', 0);

        // Act
        $this->from(route('home'))
            ->post(route('subscribe.newsletter'), [
                'email' => $validEmail
            ]);

        // Assert
        $this->assertDatabaseCount('invalid_emails', 1);
        $this->assertDatabaseHas('invalid_emails', [
            'email' => $validEmail
        ]);
    }

    /** @test */
    public function it_does_not_send_validation_checking_request_if_the_email_address_found_in_the_invalid_db_already()
    {
        // Arrange
        Http::fake();
        $email = "[email protected]";

        InvalidEmail::create([
            'email' => $email
        ]);

        // Act
        $this->from(route('home'))
            ->post(route('subscribe.newsletter'), [
                'email' => $email
            ]);

        // Assert
        Http::assertNothingSent();
    }

    public static function emailInValidationStatus()
    {
        return [
            'Invalid validation status' => ['invalid'],
            'Catch all validation status' => ['catch-all'],
            'Unknown validation status' => ['unknown'],
            'Spamtrap all validation status' => ['spamtrap'],
            'Abuse validation status' => ['abuse'],
            'Do not mail validation status' => ['do_not_mail'],
        ];
    }
}

Code

Here is my store method in the controller.


// Your imports

class EmailNewsletterSubscribersController extends Controller
{
    public function store(StoreEmailNewsletter $storeEmailNewsletter)
    {
        $validate = $storeEmailNewsletter->validated();

        $subscriber = Subscriber::create([
            'name' => "Guest",
            'email' => $validate['email'],
            'hash' => Str::random(32),
        ]);

        if (!InvalidEmail::where('email', $subscriber->email)->first()) {
            $isValidEmail = (new ZBServices)->isValid($subscriber->email);

            if ($isValidEmail) {
                SubscriberJoin::dispatch($subscriber);
            } else {
                InvalidEmail::firstOrCreate([
                    'email' => $subscriber->email
                ]);
            }
        }

        return redirect()->back()
            ->with('success', 'You have joined successfully. Please check your email to verify.');
    }
}

Here is the code for ZBServices:


// Your imports

class ZBServices
{
    public function isValid(string $email): bool
    {
        $apiKey = env('ZB_API_KEY');

        $response = Http::get("https://api.zerobounce.net/v2/validate", [
            'api_key' => $apiKey,
            'email' => $email,
            'ip_address' => ""
        ]);

        $data = $response->json();

        return $data['status'] === 'valid' ? true : false;
    }
}

Here is the SubscriberJoin job:


// Your imports

class SubscriberJoin implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(public Subscriber $subscriber)
    {
    }

    public function handle(): void
    {
        // Send your email via mailable class
        Mail::to($this->subscriber)->send(new SubscriberJoined($this->subscriber));
    }
}

Here is your SubscriberJoined mailable class:


// Your imports

class SubscriberJoined extends Mailable
{
    use Queueable, SerializesModels;

    public function __construct(public Subscriber $subscriber)
    {
    }

    public function build()
    {
        $subject = "Verify your account | " . env('APP_NAME');

        return $this->from(env('MAIL_FROM_ADDRESS'), env('APP_NAME'))
            ->subject($subject)
            ->view('emails.subscribers.joined');
    }
}

⚠️ Now, write you mail blade in emails.subscribers.joined.

Final Thoughts

With this approach, you can able to reduce the bounce number during sending email and which ofcourse help you to reduce the spam number if you only send email to the valid (healthy) email address.

Further Steps:

Besides my workaround, you can also implement following: