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
-
Receive Emails: Emails sent to
support@domain.com
are processed using Cloudflare Email Workers. - Store Emails: The emails are stored in Cloudflare D1 to make sure we wont loose the email even if the later processes fail.
- 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 });
}
},
};
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 }
);
}
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');
}
};
- 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");
}
}
}
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',
];
- In your
routes/api.php
add the webhook route
//existing code
Route::post('/webhook/notifications', [App\Http\Controllers\SupportEmailNotificationController::class, 'store']);
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 }
);
},
};
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)
Do not validate cloudflare_id as a string. It will fail. The right validation:
Nice catch Andreas
Getting the last row id in cloudflare workflow:
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)