├── worker-configuration.d.ts ├── .prettierrc ├── wrangler.toml ├── .editorconfig ├── package.json ├── tsconfig.json ├── src ├── middlewares │ ├── auth.ts │ └── email.ts ├── schema │ └── email.ts ├── main.ts └── controllers │ └── email.ts ├── LICENSE ├── .gitignore └── README.md /worker-configuration.d.ts: -------------------------------------------------------------------------------- 1 | interface Env { 2 | TOKEN: string; 3 | } 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 140, 3 | "singleQuote": true, 4 | "semi": true, 5 | "useTabs": true 6 | } 7 | -------------------------------------------------------------------------------- /wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "worker-email" 2 | main = "src/main.ts" 3 | compatibility_date = "2023-05-18" 4 | 5 | [env.production] 6 | 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = tab 6 | tab_width = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.yml] 13 | indent_style = space 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "worker-email", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "deploy": "wrangler publish --env production", 7 | "start": "wrangler dev" 8 | }, 9 | "devDependencies": { 10 | "@cloudflare/workers-types": "^4.20230419.0", 11 | "itty-router": "^4.0.9", 12 | "typescript": "^5.0.4", 13 | "wrangler": "^3.0.0", 14 | "prettier": "^2.8.8" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2021", 4 | "lib": ["es2021"], 5 | "jsx": "react", 6 | "module": "es2022", 7 | "moduleResolution": "node", 8 | "types": ["@cloudflare/workers-types"], 9 | "resolveJsonModule": true, 10 | "allowJs": true, 11 | "checkJs": false, 12 | "noEmit": true, 13 | "isolatedModules": true, 14 | "allowSyntheticDefaultImports": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "strict": true, 17 | "skipLibCheck": true, 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/middlewares/auth.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Middleware to check if the user is authenticated 3 | * @param request 4 | * @constructors 5 | */ 6 | const AuthMiddleware = (request: Request, env: Env) => { 7 | const token = request.headers.get('Authorization'); 8 | 9 | // Strict check for token existence 10 | if (!env.TOKEN || env.TOKEN.length === 0) { 11 | return new Response('You must set the TOKEN environment variable.', { 12 | status: 401, 13 | }); 14 | } 15 | 16 | if (token !== env.TOKEN) { 17 | return new Response('Unauthorized', { status: 401 }); 18 | } 19 | }; 20 | 21 | export default AuthMiddleware; 22 | -------------------------------------------------------------------------------- /src/middlewares/email.ts: -------------------------------------------------------------------------------- 1 | import iEmailSchema, { IEmail } from '../schema/email'; 2 | 3 | export type EmailRequest = Request & { 4 | email?: IEmail; 5 | }; 6 | 7 | /** 8 | * Middleware to validate the request body against the email schema 9 | * @param request 10 | * @constructor 11 | */ 12 | const EmailSchemaMiddleware = async (request: EmailRequest) => { 13 | const content = await request.json(); 14 | const email = iEmailSchema.safeParse(content); 15 | if (email.success) { 16 | request.email = email.data; 17 | return; 18 | } 19 | 20 | return new Response('Bad Request', { 21 | status: 400, 22 | }); 23 | }; 24 | 25 | export default EmailSchemaMiddleware; 26 | -------------------------------------------------------------------------------- /src/schema/email.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | const iContactSchema = z.union([ 4 | z.string(), 5 | z.object({ 6 | email: z.string(), 7 | name: z.union([z.string(), z.undefined()]), 8 | }), 9 | ]); 10 | 11 | const iEmailSchema = z.object({ 12 | to: z.union([iContactSchema, z.array(iContactSchema)]), 13 | replyTo: z.union([iContactSchema, z.array(iContactSchema)]).optional(), 14 | cc: z.union([iContactSchema, z.array(iContactSchema)]).optional(), 15 | bcc: z.union([iContactSchema, z.array(iContactSchema)]).optional(), 16 | from: iContactSchema, 17 | subject: z.string(), 18 | text: z.union([z.string(), z.undefined()]), 19 | html: z.union([z.string(), z.undefined()]), 20 | }); 21 | 22 | export type IContact = z.infer; 23 | export type IEmail = z.infer; 24 | export default iEmailSchema; 25 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { IRequest, Router } from 'itty-router'; 2 | import Email from './controllers/email'; 3 | import AuthMiddleware from './middlewares/auth'; 4 | import EmailSchemaMiddleware, { EmailRequest } from './middlewares/email'; 5 | import { IEmail } from './schema/email'; 6 | 7 | const router = Router(); 8 | 9 | // POST /api/email 10 | router.post('/api/email', AuthMiddleware, EmailSchemaMiddleware, async (request) => { 11 | const email = request.email as IEmail; 12 | 13 | try { 14 | await Email.send(email); 15 | } catch (e) { 16 | console.error(`Error sending email: ${e}`); 17 | return new Response('Internal Server Error', { status: 500 }); 18 | } 19 | 20 | return new Response('OK', { status: 200 }); 21 | }); 22 | 23 | router.all('*', (request) => new Response('Not Found', { status: 404 })); 24 | 25 | export default { 26 | fetch: router.handle, 27 | }; 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Shayan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | .idea 10 | 11 | # Diagnostic reports (https://nodejs.org/api/report.html) 12 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 13 | 14 | # Runtime data 15 | pids 16 | *.pid 17 | *.seed 18 | *.pid.lock 19 | 20 | # Directory for instrumented libs generated by jscoverage/JSCover 21 | lib-cov 22 | 23 | # Coverage directory used by tools like istanbul 24 | coverage 25 | *.lcov 26 | 27 | # nyc test coverage 28 | .nyc_output 29 | 30 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 31 | .grunt 32 | 33 | # Bower dependency directory (https://bower.io/) 34 | bower_components 35 | 36 | # node-waf configuration 37 | .lock-wscript 38 | 39 | # Compiled binary addons (https://nodejs.org/api/addons.html) 40 | build/Release 41 | 42 | # Dependency directories 43 | node_modules/ 44 | jspm_packages/ 45 | 46 | # Snowpack dependency directory (https://snowpack.dev/) 47 | web_modules/ 48 | 49 | # TypeScript cache 50 | *.tsbuildinfo 51 | 52 | # Optional npm cache directory 53 | .npm 54 | 55 | # Optional eslint cache 56 | .eslintcache 57 | 58 | # Optional stylelint cache 59 | .stylelintcache 60 | 61 | # Microbundle cache 62 | .rpt2_cache/ 63 | .rts2_cache_cjs/ 64 | .rts2_cache_es/ 65 | .rts2_cache_umd/ 66 | 67 | # Optional REPL history 68 | .node_repl_history 69 | 70 | # Output of 'npm pack' 71 | *.tgz 72 | 73 | # Yarn Integrity file 74 | .yarn-integrity 75 | 76 | # dotenv environment variable files 77 | .env 78 | .dev.vars 79 | .env.development.local 80 | .env.test.local 81 | .env.production.local 82 | .env.local 83 | 84 | # parcel-bundler cache (https://parceljs.org/) 85 | .cache 86 | .parcel-cache 87 | 88 | # Next.js build output 89 | .next 90 | out 91 | 92 | # Nuxt.js build / generate output 93 | .nuxt 94 | dist 95 | 96 | # Gatsby files 97 | .cache/ 98 | # Comment in the public line in if your project uses Gatsby and not Next.js 99 | # https://nextjs.org/blog/next-9-1#public-directory-support 100 | # public 101 | 102 | # vuepress build output 103 | .vuepress/dist 104 | 105 | # vuepress v2.x temp and cache directory 106 | .temp 107 | .cache 108 | 109 | # Docusaurus cache and generated files 110 | .docusaurus 111 | 112 | # Serverless directories 113 | .serverless/ 114 | 115 | # FuseBox cache 116 | .fusebox/ 117 | 118 | # DynamoDB Local files 119 | .dynamodb/ 120 | 121 | # TernJS port file 122 | .tern-port 123 | 124 | # Stores VSCode versions used for testing VSCode extensions 125 | .vscode-test 126 | 127 | # yarn v2 128 | .yarn/cache 129 | .yarn/unplugged 130 | .yarn/build-state.yml 131 | .yarn/install-state.gz 132 | .pnp.* 133 | -------------------------------------------------------------------------------- /src/controllers/email.ts: -------------------------------------------------------------------------------- 1 | import { IContact, IEmail } from '../schema/email'; 2 | 3 | type IMCPersonalization = { to: IMCContact[] }; 4 | type IMCContact = { email: string; name: string | undefined }; 5 | type IMCContent = { type: string; value: string }; 6 | 7 | interface IMCEmail { 8 | personalizations: IMCPersonalization[]; 9 | from: IMCContact; 10 | reply_to: IMCContact | undefined; 11 | cc: IMCContact[] | undefined; 12 | bcc: IMCContact[] | undefined; 13 | subject: string; 14 | content: IMCContent[]; 15 | } 16 | 17 | class Email { 18 | /** 19 | * 20 | * @param email 21 | */ 22 | static async send(email: IEmail) { 23 | // convert email to IMCEmail (MailChannels Email) 24 | const mcEmail: IMCEmail = Email.convertEmail(email); 25 | 26 | // send email through MailChannels 27 | const resp = await fetch( 28 | new Request('https://api.mailchannels.net/tx/v1/send', { 29 | method: 'POST', 30 | headers: { 31 | 'content-type': 'application/json', 32 | }, 33 | body: JSON.stringify(mcEmail), 34 | }) 35 | ); 36 | 37 | // check if email was sent successfully 38 | if (resp.status > 299 || resp.status < 200) { 39 | throw new Error(`Error sending email: ${resp.status} ${resp.statusText}`); 40 | } 41 | } 42 | 43 | /** 44 | * Converts an IEmail to an IMCEmail 45 | * @param email 46 | * @protected 47 | */ 48 | protected static convertEmail(email: IEmail): IMCEmail { 49 | const personalizations: IMCPersonalization[] = []; 50 | 51 | // Convert 'to' field 52 | const toContacts: IMCContact[] = Email.convertContacts(email.to); 53 | personalizations.push({ to: toContacts }); 54 | 55 | let replyTo: IMCContact | undefined = undefined; 56 | let bccContacts: IMCContact[] | undefined = undefined; 57 | let ccContacts: IMCContact[] | undefined = undefined; 58 | 59 | // Convert 'replyTo' field 60 | if (email.replyTo) { 61 | const replyToContacts: IMCContact[] = Email.convertContacts(email.replyTo); 62 | replyTo = replyToContacts.length > 0 ? replyToContacts[0] : { email: '', name: undefined }; 63 | } 64 | 65 | // Convert 'cc' field 66 | if (email.cc) { 67 | ccContacts = Email.convertContacts(email.cc); 68 | } 69 | 70 | // Convert 'bcc' field 71 | if (email.bcc) { 72 | bccContacts = Email.convertContacts(email.bcc); 73 | } 74 | 75 | const from: IMCContact = Email.convertContact(email.from); 76 | 77 | // Convert 'subject' field 78 | const subject: string = email.subject; 79 | 80 | // Convert 'text' field 81 | const textContent: IMCContent[] = []; 82 | if (email.text) { 83 | textContent.push({ type: 'text/plain', value: email.text }); 84 | } 85 | 86 | // Convert 'html' field 87 | const htmlContent: IMCContent[] = []; 88 | if (email.html) { 89 | htmlContent.push({ type: 'text/html', value: email.html }); 90 | } 91 | 92 | const content: IMCContent[] = [...textContent, ...htmlContent]; 93 | 94 | return { 95 | personalizations, 96 | from, 97 | cc: ccContacts, 98 | bcc: bccContacts, 99 | reply_to: replyTo, 100 | subject, 101 | content, 102 | }; 103 | } 104 | 105 | /** 106 | * Converts an IContact or IContact[] to a Contact[] 107 | * @param contacts 108 | * @protected 109 | */ 110 | protected static convertContacts(contacts: IContact | IContact[]): IMCContact[] { 111 | if (!contacts) { 112 | return []; 113 | } 114 | 115 | const contactArray: IContact[] = Array.isArray(contacts) ? contacts : [contacts]; 116 | const convertedContacts: IMCContact[] = contactArray.map(Email.convertContact); 117 | 118 | return convertedContacts; 119 | } 120 | 121 | /** 122 | * Converts an IContact to a Contact 123 | * @param contact 124 | * @protected 125 | */ 126 | protected static convertContact(contact: IContact): IMCContact { 127 | if (typeof contact === 'string') { 128 | return { email: contact, name: undefined }; 129 | } 130 | 131 | return { email: contact.email, name: contact.name }; 132 | } 133 | } 134 | 135 | export default Email; 136 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > [!WARNING] 2 | > [As of August 31, 2024, MailChannels has sunset its free email sending service for Cloudflare Workers users.](https://support.mailchannels.com/hc/en-us/articles/4565898358413-Sending-Email-from-Cloudflare-Workers-using-MailChannels-Email-API). 3 | 4 |
5 | Cloudflare Worker Email Server 6 |
7 |

Cloudflare Worker Email Server

8 |

Send free transactional emails from your Cloudflare Workers using MailChannels.

9 |
10 | 11 | 12 | ## Getting Started! 13 | 14 | 1. Clone this repository 15 | 2. Install the dependencies with `npm install` 16 | 3. Use the command `npx wrangler secret put --env production TOKEN` to deploy a securely stored token to Cloudflare. With this command, you will be prompted to enter a random secret value, which will be used to authenticate your requests with the HTTP `Authorization` header as described below. You can also set this encrypted value directly in your Cloudflare dashboard. 17 | 4. Deploy the worker with `npm run deploy` 18 | 19 | Or deploy directly to Cloudflare 20 | 21 | [![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/Sh4yy/cloudflare-email) 22 | 23 | ## Setup SPF 24 | 25 | SPF is a DNS record that helps prevent email spoofing. You will need to add an SPF record to your domain to allow MailChannels to send emails on your behalf. 26 | 27 | 1. Add a `TXT` record to your domain with the following values: 28 | 29 | - Name: `@` 30 | - Value: `v=spf1 a mx include:relay.mailchannels.net ~all` 31 | 32 | Note: If you're facing [Domain Lockdown error](https://support.mailchannels.com/hc/en-us/articles/16918954360845-Secure-your-domain-name-against-spoofing-with-Domain-Lockdown), follow the below steps: 33 | 34 | 1. Create a `TXT` record with the following values: 35 | 36 | - Name: `_mailchannels` 37 | - Value: `v=mc1 cfid=yourdomain.workers.dev` (the value of `cfid` will also be present in the error response) 38 | 39 | ## Setup DKIM 40 | 41 | This step is optional, but highly recommended. DKIM is a DNS record that helps prevent email spoofing. You may follow the steps listed in the [MailChannels documentation](https://support.mailchannels.com/hc/en-us/articles/7122849237389-Adding-a-DKIM-Signature) to set up DKIM for your domain. 42 | 43 | ## Usage 44 | 45 | Once you have deployed this worker function to Cloudflare Workers, you can send emails by making a `POST` request to the worker on the `/api/email` endpoint with the following parameters: 46 | 47 | - Note you need to pass an `Authorization` header with the secure token you deployed. Like the following: `Authorization: TOKEN` 48 | 49 | ### Basic Email 50 | 51 | The Most basic request would look like this: 52 | 53 | ```json 54 | { 55 | "to": "john@example.com", 56 | "from": "me@example.com", 57 | "subject": "Hello World", 58 | "text": "Hello World" 59 | } 60 | ``` 61 | 62 | ### HTML Emails 63 | 64 | You can also send HTML emails by adding an `html` parameter to the request. This can be used in conjunction with the `text` parameter to send a multi-part email. 65 | 66 | ```json 67 | { 68 | "to": "john@example.com", 69 | "from": "me@example.com", 70 | "subject": "Hello World", 71 | "html": "

Hello World

" 72 | } 73 | ``` 74 | 75 | ### Sender and Recipient Name 76 | 77 | You can also specify a sender and recipient name by adding a `name` parameter to the request. This can be used for both the `to` and `from` parameters. 78 | 79 | ```json 80 | { 81 | "to": { "email": "john@example.com", "name": "John Doe" }, 82 | "from": { "email": "me@example.com", "name": "Jane Doe" }, 83 | "subject": "Hello World", 84 | "text": "Hello World" 85 | } 86 | ``` 87 | 88 | ### Sending to Multiple Recipients 89 | 90 | You may also send to multiple recipients by passing an array of emails, or an array of objects with `email` and `name` properties. 91 | 92 | ```json 93 | { 94 | "to": [ 95 | "john@example.com", 96 | "rose@example.com" 97 | ], 98 | "from": "me@example.com", 99 | "subject": "Hello World", 100 | "text": "Hello World" 101 | } 102 | ``` 103 | 104 | or 105 | 106 | ```json 107 | { 108 | "to": [ 109 | { "email": "john@example.com", "name": "John Doe" }, 110 | { "email": "rose@example.com", "name": "Rose Doe" } 111 | ], 112 | "from": "me@example.com", 113 | "subject": "Hello World", 114 | "text": "Hello World" 115 | } 116 | ``` 117 | 118 | ### Sending BCC and CC 119 | 120 | You can also send BCC and CC emails by passing an array of emails, an object with `email` and `name` properties, or an array of either, similar to the `to` parameter. 121 | 122 | ```json 123 | { 124 | "to": "john@example.com", 125 | "from": "me@example.com", 126 | "subject": "Hello World", 127 | "text": "Hello World", 128 | "cc": [ 129 | "jim@example.com", 130 | "rose@example.com" 131 | ], 132 | "bcc": [ 133 | "gil@example.com" 134 | ] 135 | } 136 | ``` 137 | 138 | ### Reply To 139 | 140 | You can also specify a reply to email address by adding a `replyTo` parameter to the request. Again, you can use an email string, an object with `email` and `name` properties, or an array of either. 141 | 142 | ```json 143 | { 144 | "to": "john@example.com", 145 | "from": "me@example.com", 146 | "replyTo": "support@example.com", 147 | "subject": "Hello World", 148 | "text": "Hello World" 149 | } 150 | ``` 151 | --------------------------------------------------------------------------------