DEV Community

Cover image for How to Process Incoming Emails and Trigger Webhooks, In-App Actions, and More Using Cloudflare Email Workers and D1 Database
Elvis Ansima
Elvis Ansima

Posted on • Edited on

How to Process Incoming Emails and Trigger Webhooks, In-App Actions, and More Using Cloudflare Email Workers and D1 Database

It's common to have shared mailboxes across teams in companies, eg. contact emails or support email.
Managing them solely from emails clients can be cumbersome. In this article, we’ll demonstrate how to turn received emails into in-app notifications or tickets using Cloudflare Email Workers and D1 database. We will then process the emails message into a Laravel Application.

Overview

Workflow

  1. Receive Emails: Emails sent to support@domain.com are processed using Cloudflare Email Workers.
  2. Store Emails: The emails are stored in Cloudflare D1 to make sure we wont loose the email even if the later processes fail.
  3. Webhook Call to Laravel: Cloudflare sends the email data to Laravel through a webhook.

Why Use This Approach?

  • Centralized Management: Manage tickets and notifications within your app.
  • Cloudflare’s Power: Leverage Cloudflare’s global edge network and D1 database.
  • Scalability: Handle a high volume of emails efficiently.

Step 1: Set Up Cloudflare Email Worker

Cloudflare’s Email Workers allow you to intercept emails and process them programmatically. Once your domain is paired with your Cloudflare, and email routing successfully configured, you can decide what happen when an email is received on a given mail address

Example Worker Code that save a mail content into D1

export default {
  async email(message, env, ctx) {
    try {
      // Parse email details
      const subject = message.headers.get('subject') || 'No Subject';
      const from = message.from || 'Unknown Sender';
      const rawEmail = await new Response(message.raw).text();

      // Prepare SQL query for storing email
      const query = `
        INSERT INTO emails (sender, subject, body, receivedAt) 
        VALUES (?, ?, ?, ?);
      `;
      const values = [from, subject, rawEmail, new Date().toISOString()];

      // Execute the SQL query
      await env.D1_DATABASE.prepare(query).bind(...values).run();

      // Return success response
      return new Response('Email stored successfully!', { status: 200 });
    } catch (error) {
      // Notify webhook about the error
      const webhookURL = 'https://webhook.site/d1ff620a-xxxxxxxxxxxxxxxxxx';
      const errorDetails = {
        message: 'Error occurred while storing email',
        error: error.toString(),
        stack: error.stack || null,
        timestamp: new Date().toISOString(),
      };

      // Post the error details to the webhook
      await fetch(webhookURL, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(errorDetails),
      });

      // Log the error for debugging
      console.error('Error storing email:', error);

      // Return error response
      return new Response('Failed to store email', { status: 500 });
    }
  },
};

Enter fullscreen mode Exit fullscreen mode

The above code is using email worker syntax and assume you have a D1 Database binding available under the D1_DATABASE env variable

Configure Email Routing

  • Set up an email address like support@domain.com in Cloudflare’s Email Routing.
  • Route emails to your worker for processing. For more information, please refer to the documentation

Step 2: Extend the Worker

Add REST API endpoints to fetch emails and mark them as sent.

Updated Worker Code

  async fetch(request, env) {
    const url = new URL(request.url);

    // Fetch emails that are not marked as read
    if (url.pathname === '/emails' && request.method === 'GET') {
      try {
        const query = `SELECT * FROM emails WHERE readAt IS NULL ORDER BY id DESC;`;
        const emails = await env.D1_DATABASE.prepare(query).all();

        return new Response(
          JSON.stringify({
            status: 'success',
            data: emails.results || [],
          }),
          { headers: { 'Content-Type': 'application/json' }, status: 200 }
        );
      } catch (err) {
        console.error('Error retrieving emails:', err);
        return new Response(
          JSON.stringify({
            status: 'error',
            message: 'Error retrieving emails',
            error: err.message,
          }),
          { headers: { 'Content-Type': 'application/json' }, status: 500 }
        );
      }
    }

    // Mark email as read
    if (url.pathname.startsWith('/emails/') && request.method === 'PATCH') {
      const id = url.pathname.split('/').pop();

      if (!id || isNaN(id)) {
        return new Response(
          JSON.stringify({
            status: 'error',
            message: 'Invalid email ID provided',
          }),
          { headers: { 'Content-Type': 'application/json' }, status: 400 }
        );
      }

      try {
        const query = `UPDATE emails SET readAt = ? WHERE id = ?;`;
        const result = await env.D1_DATABASE.prepare(query).bind(new Date().toISOString(), id).run();

        if (result.changes === 0) {
          return new Response(
            JSON.stringify({
              status: 'error',
              message: `Email with ID ${id} not found or already marked as read.`,
            }),
            { headers: { 'Content-Type': 'application/json' }, status: 404 }
          );
        }

        return new Response(
          JSON.stringify({
            status: 'success',
            message: `Email with ID ${id} marked as read.`,
          }),
          { headers: { 'Content-Type': 'application/json' }, status: 200 }
        );
      } catch (err) {
        console.error('Error updating email status:', err);
        return new Response(
          JSON.stringify({
            status: 'error',
            message: 'Error updating email status',
            error: err.message,
          }),
          { headers: { 'Content-Type': 'application/json' }, status: 500 }
        );
      }
    }

    // Default 404 response
    return new Response(
      JSON.stringify({
        status: 'error',
        message: 'Endpoint not found',
      }),
      { headers: { 'Content-Type': 'application/json' }, status: 404 }
    );
  }
Enter fullscreen mode Exit fullscreen mode

We have added the fetch handler that help us create HTTP endpoints from where we can fetch emails. you can learn more about the fetch handler here


Step 3: Integrate into a Laravel Project

I Assume you have a working Laravel application, and if not or you are new to laravel please see bootcamp.laravel.com.

We will need a model to store the processed emails and a webhook endpoint to receive the payload from the Cloudflare worker anytime a new mail is received.

In your project, create a model, the migration file and the controller by running the following command php artisan make:model SupportEmailNotification -mc, then :

  • In the migration file :
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('support_email_notifications', function (Blueprint $table) {
            $table->id();
            $table->string('sender');
            $table->string('subject');
            $table->longText('html_body');
            $table->longText('text_body');
            $table->boolean('read')->default(false);
            $table->timestamps();
            $table->string('cloudflare_id')->unique();
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('support_email_notifications');
    }
};
Enter fullscreen mode Exit fullscreen mode
  • In the controller file :
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use App\Models\SupportEmailNotification;
use Exception;
use PhpMimeMailParser\Parser;
use Illuminate\Support\Facades\Http;


class SupportEmailNotificationController extends Controller
{
    public function store(Request $request)
    {
        $validated = $request->validate(
            [
            'cloudflare_id' => 'required|string|unique:support_email_notifications,cloudflare_id',
            'sender' => 'required|string|max:255',
            'subject' => 'required|string|max:255',
            'raw_body' => 'required|string',
            ]
        );

        try{

            $parser = new Parser();
            $parser->setText($validated['raw_body']);

            // Extract email details
            $htmlBody = $parser->getMessageBody('html') ?? '';
            $textBody = $parser->getMessageBody('text') ?? '';

            // Save the email details to the database
            $emailNotification = new SupportEmailNotification();
            $emailNotification->sender = $validated['sender'];
            $emailNotification->subject = $validated['subject'];
            $emailNotification->html_body = $htmlBody;
            $emailNotification->text_body = $textBody;
            $emailNotification->read = false;
            $emailNotification->cloudflare_id = $validated['cloudflare_id'];
            $emailNotification->save();

            // Call Cloudflare read route to confirm reception
            $cloudflareId = $request->input('cloudflare_id');
            if ($cloudflareId) {
                $this->markAsReadInCloudflare($cloudflareId);
            }

            return response()->json(
                [
                'status' => 'success',
                ], 201
            );
        } catch (\Exception $e) {
            // Handle errors and return a 500 response
            return response()->json(['error' => $e->getMessage()], 500);
        }
    }

    private function markAsReadInCloudflare($cloudflareId)
    {
        //$url = "https://your-cloudflare-worker-domain/emails/{$cloudflareId}";
        $response = Http::patch($url);
        if (!$response->successful()) {
            throw new \Exception("Failed to mark email as read in Cloudflare. ID: $cloudflareId");
        }
    }

}
Enter fullscreen mode Exit fullscreen mode

You must install the PHP-Mime-Mail-Parser that help us process the raw email content

  • In your model file, add the $fillable property
    protected $fillable = [
        'sender',
        'subject',
        'html_body',
        'text_body',
        'read',
        'cloudflare_id',
        'read_at',
    ];
Enter fullscreen mode Exit fullscreen mode
  • In your routes/api.php add the webhook route
//existing code
Route::post('/webhook/notifications', [App\Http\Controllers\SupportEmailNotificationController::class, 'store']);
Enter fullscreen mode Exit fullscreen mode

Finally run the migration : php artisan migrate.
The very last step is to update our cloudflare worker so that it will send a webhook call to our application whenever a new email is received.

export default {
  async email(message, env, ctx) {
    const webhookURL = 'https://domain.com/api/webhook/notifications';
    const errorWebHookURL = 'https://webhook.site/xxxxx-ab25-4f8b-8957-8d937d0d49d6';
    try {
      // Parse email details
      const subject = message.headers.get('subject') || 'No Subject';
      const from = message.from || 'Unknown Sender';
      const rawEmail = await new Response(message.raw).text();

      // Prepare SQL query for storing email
      const query = `
        INSERT INTO emails (sender, subject, body, receivedAt) 
        VALUES (?, ?, ?, ?);
      `;
      const values = [from, subject, rawEmail, new Date().toISOString()];

      // Execute the SQL query
      const result = await env.D1_DATABASE.prepare(query).bind(...values).run();

      // Notify webhook of the successfully stored email
      const webhookPayload = {
        cloudflare_id: result.meta.last_row_id.toString(),
        sender: from,
        subject: subject,
        raw_body: rawEmail,
        received_at: new Date().toISOString(),
      };

      await fetch(webhookURL, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(webhookPayload),
      });

      // Return success response
      return new Response(
        JSON.stringify({ status: 'success', message: 'Email stored successfully!' }),
        { headers: { 'Content-Type': 'application/json' }, status: 200 }
      );
    } catch (error) {
      // Notify webhook about the error
      const errorDetails = {
        message: 'Error occurred while storing email',
        error: error.toString(),
        stack: error.stack || null,
        timestamp: new Date().toISOString(),
      };

      await fetch(errorWebHookURL, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(errorDetails),
      });

      // Log the error for debugging
      console.error('Error storing email:', error);

      // Return error response
      return new Response(
        JSON.stringify({ status: 'error', message: 'Failed to store email', error: error.message }),
        { headers: { 'Content-Type': 'application/json' }, status: 500 }
      );
    }
  },

  async fetch(request, env) {
    const url = new URL(request.url);

    // Fetch emails that are not marked as read
    if (url.pathname === '/emails' && request.method === 'GET') {
      try {
        const query = `SELECT * FROM emails WHERE readAt IS NULL ORDER BY id DESC;`;
        const emails = await env.D1_DATABASE.prepare(query).all();

        return new Response(
          JSON.stringify({ status: 'success', data: emails.results || [] }),
          { headers: { 'Content-Type': 'application/json' }, status: 200 }
        );
      } catch (err) {
        console.error('Error retrieving emails:', err);
        return new Response(
          JSON.stringify({ status: 'error', message: 'Error retrieving emails', error: err.message }),
          { headers: { 'Content-Type': 'application/json' }, status: 500 }
        );
      }
    }

    // Mark email as read
    if (url.pathname.startsWith('/emails/') && request.method === 'PATCH') {
      const id = url.pathname.split('/').pop();

      if (!id || isNaN(id)) {
        return new Response(
          JSON.stringify({ status: 'error', message: 'Invalid email ID provided' }),
          { headers: { 'Content-Type': 'application/json' }, status: 400 }
        );
      }

      try {
        const query = `UPDATE emails SET readAt = ? WHERE id = ?;`;
        const result = await env.D1_DATABASE.prepare(query).bind(new Date().toISOString(), id).run();

        if (result.changes === 0) {
          return new Response(
            JSON.stringify({ status: 'error', message: `Email with ID ${id} not found or already marked as read.` }),
            { headers: { 'Content-Type': 'application/json' }, status: 404 }
          );
        }

        return new Response(
          JSON.stringify({ status: 'success', message: `Email with ID ${id} marked as read.` }),
          { headers: { 'Content-Type': 'application/json' }, status: 200 }
        );
      } catch (err) {
        console.error('Error updating email status:', err);
        return new Response(
          JSON.stringify({ status: 'error', message: 'Error updating email status', error: err.message }),
          { headers: { 'Content-Type': 'application/json' }, status: 500 }
        );
      }
    }

    // Default 404 response
    return new Response(
      JSON.stringify({ status: 'error', message: 'Endpoint not found' }),
      { headers: { 'Content-Type': 'application/json' }, status: 404 }
    );
  },
};

Enter fullscreen mode Exit fullscreen mode

You may have noticed the error webhook is from https://webhook.site which is a pretty cool free tool that helps you test webhook quickly. To call your local laravel instance from the cloudflare worker, you may need to use tunneling software like Cloudflare Tunnel


With Cloudflare email workers we can create a powerful, flexible system to manage emails in various ways.

Some practical examples include:

  • Turn Emails into Support Tickets: Automatically convert incoming customer emails into tickets, parsed and stored in the D1 database, ready for team follow-up.
  • Automate Reminders: Trigger automated task or deadline reminders based on email content.
  • In-App Notifications: Use email content to send real-time notifications to users within your app (e.g., account updates, special offers).

Going further we may study how to implement machine learning algorithms to categorize emails more accurately or trigger custom actions based on the email content and predefined actions.

Top comments (4)

Collapse
 
ekandreas profile image
Andreas Ek • Edited

Do not validate cloudflare_id as a string. It will fail. The right validation:

        $validated = $request->validate(
            [
                'cloudflare_id' => 'required|integer|unique:support_email_notifications,cloudflare_id',
                'sender' => 'required|string|max:255',
                'subject' => 'required|string|max:255',
                'raw_body' => 'required|string',
                'received_at' => 'required|string',
            ]
        );
Enter fullscreen mode Exit fullscreen mode
Collapse
 
elvisans profile image
Elvis Ansima

Nice catch Andreas

Collapse
 
ekandreas profile image
Andreas Ek • Edited

Getting the last row id in cloudflare workflow:

  // Notify webhook of the successfully stored email
  const webhookPayload = {
    cloudflare_id: result.meta.last_row_id,
    sender: from,
    subject: subject,
    raw_body: rawEmail,
    received_at: new Date().toISOString(),
  };
Enter fullscreen mode Exit fullscreen mode
Collapse
 
elvisans profile image
Elvis Ansima • Edited

Related to the 1st comment, the validation should remain as is (string) in order to stay consistent with the table schema, however the worker code here on the article needed to be updated to correctly get the last row id (the corrected code is already in the article)

cloudflare_id: result.meta.last_row_id.toString(),
Enter fullscreen mode Exit fullscreen mode