Building a Serverless Mail Service with Cloudflare Workers (Zero Dependencies!)
Building a Zero-Dependency Mail Service with Cloudflare Workers and Google App Passwords
Have you ever needed to send transactional emails, like contact form submissions, from a static website or a markdown blog? Usually, you'd reach for a third-party API like Resend, Sendgrid, or Mailgun, or rely on heavy Node.js packages like nodemailer.
But what if you want to use your existing Google Workspace or Gmail account to send these emails natively from the edge?
In this tutorial, we will build a zero-dependency, lightning-fast mailservicing API using Cloudflare Workers and the new cloudflare:sockets API. We will literally be speaking SMTP directly to Google's servers over raw TCP!
Why Native Sockets?
Cloudflare Workers provide a fantastic edge-computing environment. Historically, sending emails via SMTP was tricky because Workers run in an Edge runtime, not a full Node.js environment, meaning packages like nodemailer often fail with connection timeouts.
Instead of fighting with polyfills or downloading hundreds of kilobytes of dependencies, we can write a tiny native SMTP client that uses Cloudflare's native connect() API!
Prerequisites
Before we start, you'll need:
- A Cloudflare account.
- Node.js installed on your machine.
- A Google/Gmail Account with 2-Step Verification enabled.
Step 1: Generate a Google App Password
Since Google disables basic SMTP authentication using your main password for security reasons, you must generate an App Password:
- Go to your Google Account management dashboard.
- Navigate to Security.
- Under "How you sign in to Google," make sure 2-Step Verification is turned ON.
- Click on 2-Step Verification, scroll down to App passwords, and click it.
- Create a new app password (name it something like
Cloudflare Worker). - Save this 16-character password. You will not be able to see it again!
Step 2: Initialize the Project
We'll start by creating a new directory and initializing our project.
1mkdir mailservicing 2cd mailservicing 3npm init -y
Now, let's install the necessary Typescript dependencies (notice we aren't installing any email packages!):
1npm install -D typescript @cloudflare/workers-types wrangler
Step 3: Configure Cloudflare Worker
Create a wrangler.toml file in the root of your project.
1name = "mailservicing" 2main = "src/index.ts" 3compatibility_date = "2025-08-20" 4 5# [vars] 6# GMAIL_USER = "your-email@gmail.com" 7# GMAIL_APP_PASSWORD = "your-app-password"
Step 4: Write the Custom SMTP Client
We will organize our code in a modular, production-grade structure inside the src folder.
1. The Types (src/types.ts)
Let's define our environment variables and request payload.
1export interface Env { 2 GMAIL_USER: string; 3 GMAIL_APP_PASSWORD: string; 4} 5 6export interface EmailRequest { 7 to: string; 8 subject: string; 9 text?: string; 10 html?: string; 11}
2. The Raw Socket Client (src/smtp-client.ts)
This class handles reading from and writing to the raw TCP stream.
1export class SMTPClient { 2 private buffer = ''; 3 private decoder = new TextDecoder(); 4 private reader: ReadableStreamDefaultReader; 5 private writer: WritableStreamDefaultWriter; 6 7 constructor(socket: any) { 8 this.reader = socket.readable.getReader(); 9 this.writer = socket.writable.getWriter(); 10 } 11 12 async readResponse(): Promise<string> { 13 let response = ''; 14 while (true) { 15 while (!this.buffer.includes('\\r\\n')) { 16 const { value, done } = await this.reader.read(); 17 if (done && !value) { 18 if (this.buffer.length > 0) break; 19 throw new Error('Socket closed unexpectedly by server'); 20 } 21 if (value) this.buffer += this.decoder.decode(value, { stream: true }); 22 } 23 24 const newlineIndex = this.buffer.indexOf('\\r\\n'); 25 let line = ''; 26 if (newlineIndex !== -1) { 27 line = this.buffer.substring(0, newlineIndex + 2); 28 this.buffer = this.buffer.substring(newlineIndex + 2); 29 } else { 30 line = this.buffer; 31 this.buffer = ''; 32 } 33 34 response += line; 35 36 // SMTP multi-line responses end with a space (e.g., "250 OK") 37 if (/^\\d{3} /.test(line) || line === '') break; 38 } 39 return response; 40 } 41 42 async sendCommand(command: string, expectedCode: string): Promise<string> { 43 const encoder = new TextEncoder(); 44 await this.writer.write(encoder.encode(command + '\\r\\n')); 45 46 const response = await this.readResponse(); 47 if (!response.startsWith(expectedCode)) { 48 throw new Error(`SMTP Error. Command: ${command.split(' ')[0]} | Expected ${expectedCode}, got: ${response}`); 49 } 50 return response; 51 } 52 53 upgrade(newSocket: any) { 54 this.reader = newSocket.readable.getReader(); 55 this.writer = newSocket.writable.getWriter(); 56 } 57 58 release() { 59 this.writer.releaseLock(); 60 this.reader.releaseLock(); 61 } 62}
3. The Mailer Service (src/mailer.ts)
Here is the magic. We use Cloudflare's connect() to establish a connection to smtp.gmail.com on port 587. We explicitly send the STARTTLS command and then instantly upgrade the socket using socket.startTls()!
1import { connect } from 'cloudflare:sockets'; 2import { SMTPClient } from './smtp-client'; 3import { Env, EmailRequest } from './types'; 4 5export async function sendEmail(env: Env, request: EmailRequest): Promise<void> { 6 const { to, subject, text, html } = request; 7 8 let socket = connect({ hostname: 'smtp.gmail.com', port: 587 }, { secureTransport: 'starttls', allowHalfOpen: false }); 9 const client = new SMTPClient(socket); 10 11 try { 12 await client.readResponse(); 13 14 await client.sendCommand('EHLO localhost', '250'); 15 await client.sendCommand('STARTTLS', '220'); 16 17 // CRITICAL: We must release the stream locks BEFORE calling startTls() 18 client.release(); 19 socket = socket.startTls(); 20 client.upgrade(socket); 21 22 await client.sendCommand('EHLO localhost', '250'); 23 24 const authStr = btoa(`\\x00${env.GMAIL_USER}\\x00${env.GMAIL_APP_PASSWORD}`); 25 await client.sendCommand(`AUTH PLAIN ${authStr}`, '235'); 26 27 await client.sendCommand(`MAIL FROM:<${env.GMAIL_USER}>`, '250'); 28 await client.sendCommand(`RCPT TO:<${to}>`, '250'); 29 await client.sendCommand('DATA', '354'); 30 31 let message = `From: ${env.GMAIL_USER}\\r\\nTo: ${to}\\r\\nSubject: ${subject}\\r\\n`; 32 if (html) { 33 message += `Content-Type: text/html; charset="UTF-8"\\r\\n\\r\\n${html}\\r\\n.`; 34 } else { 35 message += `Content-Type: text/plain; charset="UTF-8"\\r\\n\\r\\n${text}\\r\\n.`; 36 } 37 await client.sendCommand(message, '250'); 38 await client.sendCommand('QUIT', '221'); 39 40 } finally { 41 client.release(); 42 socket.close(); 43 } 44}
4. The API Endpoint (src/index.ts)
Finally, our HTTP entry point.
1import { Env, EmailRequest } from './types'; 2import { sendEmail } from './mailer'; 3 4export default { 5 async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> { 6 const corsHeaders = { 7 'Access-Control-Allow-Origin': '*', 8 'Access-Control-Allow-Methods': 'POST, OPTIONS', 9 'Access-Control-Allow-Headers': 'Content-Type', 10 }; 11 12 if (request.method === 'OPTIONS') return new Response(null, { headers: corsHeaders }); 13 if (request.method !== 'POST') return new Response('Method Not Allowed', { status: 405, headers: corsHeaders }); 14 15 try { 16 const body = await request.json() as EmailRequest; 17 if (!body.to || !body.subject || (!body.text && !body.html)) { 18 return new Response(JSON.stringify({ error: 'Missing required fields' }), { status: 400, headers: corsHeaders }); 19 } 20 21 await sendEmail(env, body); 22 23 return new Response(JSON.stringify({ success: true, message: 'Email sent successfully via custom SMTP client!' }), { 24 status: 200, 25 headers: { ...corsHeaders, 'Content-Type': 'application/json' }, 26 }); 27 28 } catch (error: any) { 29 console.error('SMTP Error:', error); 30 return new Response(JSON.stringify({ error: 'Internal Server Error', details: error.message }), { 31 status: 500, 32 headers: { ...corsHeaders, 'Content-Type': 'application/json' }, 33 }); 34 } 35 }, 36};
Step 5: Test Locally
Create an .dev.vars file in your project root to hold your local environment variables:
1GMAIL_USER="your-email@gmail.com" 2GMAIL_APP_PASSWORD="your-app-password"
Start the local server:
1npx wrangler dev
Test it from your terminal:
1curl -X POST http://localhost:8787 \\ 2 -H "Content-Type: application/json" \\ 3 -d '{"to":"recipient@example.com", "subject":"Test from CF Worker", "text":"Hello from the edge!"}'
Step 6: Deploy to Cloudflare!
First, securely add your secrets to your Cloudflare account:
1npx wrangler secret put GMAIL_USER 2npx wrangler secret put GMAIL_APP_PASSWORD
Finally, deploy the worker!
1npx wrangler deploy
And that's it! You have built your very own native, zero-dependency SMTP client running on the edge!
Source Code
You can find the complete source code and examples for this project on our GitHub repository: https://github.com/Arisecraft/worker-mailer
