├── LICENSE ├── README.md ├── app ├── .gitignore ├── README.md ├── api.swagger.yml ├── db │ ├── README.md │ └── schema.sql ├── package.json ├── src │ └── index.ts ├── tsconfig.json └── wrangler.toml └── docs └── CDDR └── app.md /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 i365 Tech 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LetterDrop 2 | 3 | LetterDrop is a secure and efficient newsletter management service powered by Cloudflare Workers, enabling easy creation, distribution, and subscription management of newsletters. 4 | 5 | ## The story 6 | 7 | I have been using `TinyLetter` to send newsletters to my subscribers, but unfortunately, Mailchimp has now shut down this free service. This isn't the first time I've faced such an issue, whenever this happens, I lose all my subscribers and have to look for a new way to send newsletters. To avoid this recurring problem, I've decided to build my own free newsletter service. It needs to be zero-cost, easy to use, and reliable so it won't get shut down. To achieve this, I'm using Cloudflare Workers to create the service, which I've named LetterDrop. 8 | 9 | ## How to use? 10 | 11 | ### Create a newsletter 12 | 13 | 1. Create a newsletter by sending a POST request to the `/api/newsletter` endpoint like this: 14 | 15 | ```bash 16 | curl --request POST \ 17 | --url https://ld.i365.tech/api/newsletter \ 18 | --header 'CF-Access-Client-Id: <>' \ 19 | --header 'CF-Access-Client-Secret: <>' \ 20 | --header 'content-type: application/json' \ 21 | --data '{ 22 | "title": "BMPI", 23 | "description": "BMPI weekly newsletter", 24 | "logo": "https://www.bmpi.dev/images/logo.png" 25 | }' 26 | ``` 27 | 28 | 2. Offline the newsletter by sending a PUT request to the `/api/newsletter/:id/offline` endpoint like this: 29 | 30 | ```bash 31 | curl --request PUT \ 32 | --url https://ld.i365.tech/api/newsletter/9080f810-e0f7-43aa-bac8-8d1cb3ceeff4/offline \ 33 | --header 'CF-Access-Client-Id: <>' \ 34 | --header 'CF-Access-Client-Secret: <>' 35 | ``` 36 | 37 | __NOTE:__ These APIs should be protected by Cloudflare zero-trust security. That means you need to create a [service-token](https://developers.cloudflare.com/cloudflare-one/identity/service-tokens/) and use it to access these APIs. 38 | 39 | ### Subscribe or Unsubscribe to a newsletter 40 | 41 | Just go to the newsletter page and click the subscribe or unsubscribe button. e.g. [BMPI](https://ld.i365.tech/newsletter/e0b379d3-0be0-4ae5-9fe2-cd972a667cdb). 42 | 43 | Then you will receive an email to confirm your subscription or unsubscription. After that, you will receive the newsletter when it is published. 44 | 45 | __NOTE:__ The newsletter page link pattern is `https://<>/newsletter/:id`. 46 | 47 | ### Publish a newsletter 48 | 49 | The LetterDrop use the Cloudflare Email Worker to send emails. And there is a `ALLOWED_EMAILS` variable to control who can send newsletters. You can use the Cloudflare dashboard to update the variable. 50 | 51 | After that, you can publish a newsletter by sending your newsletter content to this specific email address. And the Email Worker will send the newsletter to all subscribers. 52 | 53 | __NOTE:__ 54 | 55 | - You should config the Email Worker to let it can be triggered by the specific email address. Please refer to the [Cloudflare Email Worker](https://developers.cloudflare.com/email-routing/setup/email-routing-addresses/) to know how to do it. 56 | - The newsletter email subject should be `[Newsletter-ID:<>]<>`, e.g. `[Newsletter-ID:9080f810-e0f7-43aa-bac8-8d1cb3ceeff4]BMPI Weekly Newsletter - 20240623`. 57 | 58 | ## How to deploy? 59 | 60 | To use LetterDrop, you need to create a Cloudflare account and deploy the Worker script. The Worker script is available in the `app` directory. You can deploy the Worker script using the Cloudflare Workers dashboard. 61 | 62 | __NOTE:__ You need to change the `app/wrangler.toml` file to use your config values. 63 | 64 | ### The dependencies 65 | 66 | - [Cloudflare Workers](https://workers.cloudflare.com/) 67 | - [Cloudflare Email Workers](https://developers.cloudflare.com/email-routing/email-workers/) 68 | - [Cloudflare KV](https://developers.cloudflare.com/kv/) 69 | - [Cloudflare R2](https://developers.cloudflare.com/r2/) 70 | - [Cloudflare Queues](https://developers.cloudflare.com/queues/#cloudflare-queues/) 71 | - [Cloudflare D1](https://developers.cloudflare.com/d1) 72 | - Please refer to the [app/db/README.md](app/db/README.md) file to create the database. 73 | 74 | ### Variables 75 | 76 | - `ALLOWED_EMAILS`: The list of allowed emails to create newsletters. 77 | 78 | ### How to setup the notification service? 79 | 80 | Currently LetterDrop uses [AWS SES](https://aws.amazon.com/ses/) to send emails. You need to create an AWS account and configure SES to send emails. After that, you need to create a Cloudflare Worker as a notification service. The code is very simple, you can use the ChatGPT to generate the code. 81 | 82 | ### How to handle the failed emails? 83 | 84 | LetterDrop uses the Cloudflare Queues to handle the failed emails. You can use the Cloudflare dashboard to monitor the failed emails and replay them in the dead-letter queue. 85 | 86 | ## What is the next step? 87 | 88 | The next step is to add more features to LetterDrop. 89 | 90 | - Improvments 91 | - [ ] Add the unit tests. 92 | - [ ] Add the email template. 93 | - [ ] Track the email open rate. 94 | - [ ] Support more third-party email services like SendGrid, Mailgun, etc. 95 | - [ ] Support the mulit-tenant feature. 96 | - [ ] Add the landing page. 97 | 98 | ## How to contribute? 99 | 100 | I used the GPT-4o model to generate the code for LetterDrop. That means the code is generated by the AI model, and I only need to provide the prompts to the model. This approach is very efficient and can save a lot of time. I've also recorded a [video](https://www.youtube.com/playlist?list=PL21oMWN6Y7PCqSwbwesD4_wmXEVSeeQ7h) to show how to create the LetterDrop project using the GPT-4o model. 101 | 102 | That also means you can easily customize the code by changing the prompts. You can find the prompts in the [CDDR](docs/CDDR//app.md) file. 103 | 104 | Even I use the GPT model to generate the code, I still need to review the code and test it. So if you find any issues or have any suggestions, please feel free to create an issue or pull request. And there is no restriction on the contribution, you can contribute to any part of the project by yourself or with the help of the GPT model. 105 | 106 | ## Discussion 107 | 108 | If you have any questions or suggestions, please feel free to create an issue or pull request. I'm happy to discuss with you. Or you can discuss it in this hacker news [thread](https://news.ycombinator.com/item?id=40764579). 109 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .wrangler 4 | .dev.vars 5 | 6 | # Change them to your taste: 7 | package-lock.json 8 | yarn.lock 9 | pnpm-lock.yaml 10 | bun.lockb -------------------------------------------------------------------------------- /app/README.md: -------------------------------------------------------------------------------- 1 | # i365-letter-drop 2 | 3 | ## How to run 4 | 5 | ``` 6 | npm install 7 | npm run dev 8 | ``` 9 | 10 | ``` 11 | npm run deploy 12 | ``` 13 | 14 | ## Architecture 15 | 16 | ### API Schema 17 | 18 | [swagger](./api.swagger.yml) 19 | 20 | ### Database Schema 21 | 22 | ```mermaid 23 | erDiagram 24 | Newsletter ||--o{ Subscriber : has 25 | 26 | Newsletter { 27 | string id PK 28 | string title 29 | string description 30 | string logo 31 | bool subscribable 32 | datetime createdAt 33 | datetime updatedAt 34 | } 35 | 36 | Subscriber { 37 | string email PK 38 | string newsletter_id PK 39 | bool isSubscribed 40 | datetime upsertedAt 41 | } 42 | ``` 43 | -------------------------------------------------------------------------------- /app/api.swagger.yml: -------------------------------------------------------------------------------- 1 | swagger: '2.0' 2 | info: 3 | title: Newsletter API 4 | description: API for managing newsletters 5 | version: 1.0.0 6 | 7 | paths: 8 | /api/newsletter: 9 | post: 10 | summary: Create a new newsletter 11 | description: Create a new newsletter 12 | security: 13 | - ZeroTrustAuth: [] 14 | parameters: 15 | - in: body 16 | name: body 17 | required: true 18 | schema: 19 | $ref: '#/definitions/CreateNewsletterRequest' 20 | responses: 21 | '201': 22 | description: Newsletter created successfully 23 | schema: 24 | $ref: '#/definitions/Newsletter' 25 | '500': 26 | description: Server error 27 | 28 | /api/newsletter/{newsletterId}/offline: 29 | put: 30 | summary: Take a newsletter offline 31 | description: Mark a newsletter as offline and unsubscribable 32 | security: 33 | - ZeroTrustAuth: [] 34 | parameters: 35 | - name: newsletterId 36 | in: path 37 | required: true 38 | type: string 39 | responses: 40 | '200': 41 | description: Newsletter taken offline successfully 42 | '500': 43 | description: Server error 44 | 45 | /api/subscribe/confirm: 46 | post: 47 | summary: Confirm subscription 48 | description: Confirm a subscription using the provided token 49 | parameters: 50 | - in: body 51 | name: body 52 | required: true 53 | schema: 54 | $ref: '#/definitions/SubscribeConfirmRequest' 55 | responses: 56 | '200': 57 | description: Subscription confirmed successfully 58 | '400': 59 | description: Invalid or expired token 60 | '500': 61 | description: Server error 62 | 63 | /api/subscribe/cancel: 64 | post: 65 | summary: Cancel subscription 66 | description: Cancel a subscription using the provided token 67 | parameters: 68 | - in: body 69 | name: body 70 | required: true 71 | schema: 72 | $ref: '#/definitions/SubscribeCancelRequest' 73 | responses: 74 | '200': 75 | description: Unsubscribed successfully 76 | '400': 77 | description: Invalid or expired token 78 | '500': 79 | description: Server error 80 | 81 | /api/subscribe/send-confirmation: 82 | post: 83 | summary: Send confirmation email 84 | description: Send a confirmation email with a temporary token 85 | parameters: 86 | - in: body 87 | name: body 88 | required: true 89 | schema: 90 | $ref: '#/definitions/SendConfirmationRequest' 91 | responses: 92 | '200': 93 | description: Confirmation email sent successfully 94 | '500': 95 | description: Server error 96 | 97 | /api/subscribe/send-cancellation: 98 | post: 99 | summary: Send cancellation email 100 | description: Send a cancellation email with a temporary token 101 | parameters: 102 | - in: body 103 | name: body 104 | required: true 105 | schema: 106 | $ref: '#/definitions/SendCancellationRequest' 107 | responses: 108 | '200': 109 | description: Cancellation email sent successfully 110 | '500': 111 | description: Server error 112 | 113 | /newsletter/{newsletterId}: 114 | get: 115 | summary: Get newsletter details 116 | description: View newsletter details 117 | parameters: 118 | - name: newsletterId 119 | in: path 120 | required: true 121 | type: string 122 | responses: 123 | '200': 124 | description: Newsletter details retrieved successfully 125 | schema: 126 | $ref: '#/definitions/Newsletter' 127 | '404': 128 | description: Newsletter not found 129 | '500': 130 | description: Server error 131 | 132 | definitions: 133 | CreateNewsletterRequest: 134 | type: object 135 | properties: 136 | title: 137 | type: string 138 | description: 139 | type: string 140 | logo: 141 | type: string 142 | 143 | SubscribeConfirmRequest: 144 | type: object 145 | properties: 146 | email: 147 | type: string 148 | token: 149 | type: string 150 | 151 | SubscribeCancelRequest: 152 | type: object 153 | properties: 154 | email: 155 | type: string 156 | token: 157 | type: string 158 | 159 | SendConfirmationRequest: 160 | type: object 161 | properties: 162 | email: 163 | type: string 164 | newsletterId: 165 | type: string 166 | 167 | SendCancellationRequest: 168 | type: object 169 | properties: 170 | email: 171 | type: string 172 | newsletterId: 173 | type: string 174 | 175 | Newsletter: 176 | type: object 177 | properties: 178 | id: 179 | type: string 180 | format: uuid 181 | title: 182 | type: string 183 | description: 184 | type: string 185 | logo: 186 | type: string 187 | subscriberCount: 188 | type: integer 189 | subscribable: 190 | type: boolean 191 | 192 | securityDefinitions: 193 | ZeroTrustAuth: 194 | type: apiKey 195 | in: header 196 | name: Authorization 197 | -------------------------------------------------------------------------------- /app/db/README.md: -------------------------------------------------------------------------------- 1 | # Database for the app 2 | 3 | ## Create the database 4 | 5 | ```bash 6 | wrangler d1 execute i365-letter-drop-prod --remote --file db/schema.sql 7 | ``` 8 | -------------------------------------------------------------------------------- /app/db/schema.sql: -------------------------------------------------------------------------------- 1 | -- schema.sql 2 | 3 | CREATE TABLE Newsletter ( 4 | id TEXT PRIMARY KEY, 5 | title TEXT, 6 | description TEXT, 7 | logo TEXT, 8 | subscribable BOOLEAN, 9 | createdAt DATETIME DEFAULT CURRENT_TIMESTAMP, 10 | updatedAt DATETIME DEFAULT CURRENT_TIMESTAMP 11 | ); 12 | 13 | CREATE TABLE Subscriber ( 14 | email TEXT, 15 | newsletter_id TEXT, 16 | isSubscribed BOOLEAN, 17 | upsertedAt DATETIME DEFAULT CURRENT_TIMESTAMP, 18 | PRIMARY KEY (email, newsletter_id), 19 | FOREIGN KEY (newsletter_id) REFERENCES Newsletter(id) 20 | ); 21 | -------------------------------------------------------------------------------- /app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "dev": "wrangler dev src/index.ts", 4 | "deploy": "wrangler deploy --minify src/index.ts" 5 | }, 6 | "dependencies": { 7 | "hono": "^4.3.11", 8 | "mimetext": "^3.0.24", 9 | "postal-mime": "^2.2.5" 10 | }, 11 | "devDependencies": { 12 | "@cloudflare/workers-types": "^4.20240403.0", 13 | "wrangler": "^3.47.0" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /app/src/index.ts: -------------------------------------------------------------------------------- 1 | import { Context, Hono } from 'hono' 2 | import PostalMime from "postal-mime"; 3 | 4 | type Bindings = { 5 | DB: D1Database 6 | NOTIFICATION: Fetcher 7 | KV: KVNamespace 8 | R2: R2Bucket 9 | QUEUE: Queue 10 | ALLOWED_EMAILS: string; 11 | }; 12 | 13 | const NOTIFICATION_BASE_URL = 'http://my-invest-notification' 14 | 15 | const app = new Hono<{ Bindings: Bindings }>() 16 | 17 | // Private Routes for managing Newsletters 18 | app.post('/api/newsletter', async (c: Context) => { 19 | const { title, description, logo } = await c.req.json<{ title: string, description: string, logo: string }>() 20 | 21 | const id = crypto.randomUUID() 22 | 23 | const createdAt = new Date().toISOString() 24 | const updatedAt = createdAt 25 | 26 | await c.env.R2.put(`newsletters/${id}/index.md`, `# ${title}\n\n${description}`) 27 | 28 | try { 29 | await c.env.DB.prepare( 30 | `INSERT INTO Newsletter (id, title, description, logo, subscribable, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?, ?)` 31 | ).bind(id, title, description, logo, true, createdAt, updatedAt).run() 32 | 33 | return c.json({ id, title, description, logo, subscribable: true, createdAt, updatedAt }, 201) 34 | } catch (error: any) { 35 | return c.json({ error: error.message }, 500) 36 | } 37 | }) 38 | 39 | app.put('/api/newsletter/:newsletterId/offline', async (c: Context) => { 40 | const { newsletterId } = c.req.param() 41 | 42 | try { 43 | await c.env.DB.prepare( 44 | `UPDATE Newsletter SET subscribable = ? WHERE id = ?` 45 | ).bind(false, newsletterId).run() 46 | 47 | return c.json({ message: 'Newsletter taken offline successfully' }) 48 | } catch (error: any) { 49 | return c.json({ error: error.message }, 500) 50 | } 51 | }) 52 | 53 | // Public Routes for managing Subscriptions 54 | app.get('/api/subscribe/confirm/:token', async (c: Context) => { 55 | const { token } = c.req.param() 56 | 57 | // Validate Token and Get Email 58 | const tokenString = await c.env.KV.get(token) as string 59 | if (!tokenString) { 60 | return c.json({ error: 'Invalid or expired token' }, 400) 61 | } 62 | 63 | const { email, newsletterId } = JSON.parse(tokenString) 64 | 65 | if (!email || !newsletterId) { 66 | return c.json({ error: 'Invalid or expired token' }, 400) 67 | } 68 | 69 | // Upsert Subscription 70 | await c.env.DB.prepare( 71 | `INSERT INTO Subscriber (email, newsletter_id, isSubscribed) VALUES (?, ?, ?) 72 | ON CONFLICT(email, newsletter_id) DO UPDATE SET isSubscribed = ?` 73 | ).bind(email, newsletterId, true, true).run() 74 | 75 | return renderHtml(c, 'Subscription confirmed successfully', '订阅成功确认'); 76 | }) 77 | 78 | app.get('/api/subscribe/cancel/:token', async (c: Context) => { 79 | const { token } = c.req.param() 80 | 81 | // Validate Token and Get Email 82 | const tokenString = await c.env.KV.get(token) as string 83 | if (!tokenString) { 84 | return c.json({ error: 'Invalid or expired token' }, 400) 85 | } 86 | 87 | const { email, newsletterId } = JSON.parse(tokenString) 88 | 89 | if (!email || !newsletterId) { 90 | return c.json({ error: 'Invalid or expired token' }, 400) 91 | } 92 | 93 | // Update Subscription Status 94 | await c.env.DB.prepare( 95 | `UPDATE Subscriber SET isSubscribed = ? WHERE email = ? AND newsletter_id = ?` 96 | ).bind(false, email, newsletterId).run() 97 | 98 | return renderHtml(c, 'Unsubscribed successfully', '取消订阅成功'); 99 | }) 100 | 101 | app.post('/api/subscribe/send-confirmation', async (c: Context) => { 102 | const { email, newsletterId } = await c.req.json<{ email: string, newsletterId: string }>() 103 | const token = crypto.randomUUID() 104 | const expiry = 5 * 60 * 1000 // 5 minutes 105 | 106 | // Store Token 107 | await c.env.KV.put(token, JSON.stringify({ email, newsletterId }), { expirationTtl: expiry }) 108 | 109 | // Send Confirmation Email 110 | const confirmationUrl = `https://ld.i365.tech/api/subscribe/confirm/${token}` 111 | 112 | await sendEmail(c.env, email, 'Confirm your subscription', `Please confirm your subscription by clicking the following link: ${confirmationUrl}`) 113 | 114 | return c.json({ message: 'Confirmation email sent' }) 115 | }) 116 | 117 | app.post('/api/subscribe/send-cancellation', async (c: Context) => { 118 | const { email, newsletterId } = await c.req.json<{ email: string, newsletterId: string }>() 119 | const token = crypto.randomUUID() 120 | const expiry = 5 * 60 * 1000 // 5 minutes 121 | 122 | // Store Token 123 | await c.env.KV.put(token, JSON.stringify({ email, newsletterId }), { expirationTtl: expiry }) 124 | 125 | // Send Cancellation Email 126 | const cancellationUrl = `https://ld.i365.tech/api/subscribe/cancel/${token}` 127 | 128 | await sendEmail(c.env, email, 'Cancel your subscription', `Please cancel your subscription by clicking the following link: ${cancellationUrl}`) 129 | 130 | return c.json({ message: 'Cancellation email sent' }) 131 | }) 132 | 133 | const sendEmail = async (env: Bindings, email: string, subject: string, txt: string, html: string = '') => { 134 | const res = await env.NOTIFICATION.fetch( 135 | new Request(`${NOTIFICATION_BASE_URL}/send_email`, { 136 | method: 'POST', 137 | body: JSON.stringify({ mail_to: email, subject, txt, html }), 138 | headers: { 'Content-Type': 'application/json' }, 139 | }) 140 | ); 141 | 142 | const { message } = await res.json() as { message: string }; 143 | 144 | if (message !== 'success') { 145 | console.error(`[send email failed for ${email}`); 146 | } 147 | } 148 | 149 | // Public Page for viewing Newsletters 150 | app.get('/newsletter/:newsletterId', async (c) => { 151 | const { newsletterId } = c.req.param() 152 | 153 | try { 154 | const newsletter = await c.env.DB.prepare( 155 | `SELECT * FROM Newsletter WHERE id = ?` 156 | ).bind(newsletterId).first() 157 | 158 | if (!newsletter) { 159 | return c.html('

Newsletter not found

', 404) 160 | } 161 | 162 | if (!newsletter.subscribable) { 163 | return c.html('

Newsletter is not subscribable

', 404) 164 | } 165 | 166 | const subscriberCount = await c.env.DB.prepare( 167 | `SELECT COUNT(*) as count FROM Subscriber WHERE newsletter_id = ? and isSubscribed = ?` 168 | ).bind(newsletterId, 1).first() || { count: 0 } 169 | 170 | // 获取用户语言 171 | const language = c.req.header('Accept-Language')?.startsWith('zh') ? 'zh' : 'en' 172 | 173 | const html = language === 'zh' ? ` 174 | 175 | 176 | ${newsletter.title} 177 | 246 | 285 | 286 | 287 |
288 | ${newsletter.title} Logo 289 |

${newsletter.title}

290 |

${newsletter.description}

291 |

订阅者: ${subscriberCount.count}

292 |
293 | 294 |
295 | 296 | 297 |

298 |

299 |
300 | 301 | 302 | ` : ` 303 | 304 | 305 | ${newsletter.title} 306 | 375 | 414 | 415 | 416 |
417 | ${newsletter.title} Logo 418 |

${newsletter.title}

419 |

${newsletter.description}

420 |

Subscribers: ${subscriberCount.count}

421 |
422 | 423 |
424 | 425 | 426 |

427 |

428 |
429 | 430 | 431 | ` 432 | 433 | return c.html(html) 434 | } catch (error: any) { 435 | return c.html(`

${error.message}

`, 500) 436 | } 437 | }) 438 | 439 | // Common Functions 440 | 441 | function renderHtml(c: Context, englishMessage: string, chineseMessage: string) { 442 | const language = c.req.header('Accept-Language')?.startsWith('zh') ? 'zh' : 'en'; 443 | const message = language === 'zh' ? chineseMessage : englishMessage; 444 | 445 | const html = ` 446 | 447 | 448 | 449 | 450 | 451 | ${message} 452 | 453 | 454 |

${message}

455 | 456 | 457 | `; 458 | 459 | return c.html(html); 460 | } 461 | 462 | const streamToArrayBuffer = async function (stream: ReadableStream, streamSize: number) { 463 | let result = new Uint8Array(streamSize); 464 | let bytesRead = 0; 465 | const reader = stream.getReader(); 466 | while (true) { 467 | const { done, value } = await reader.read(); 468 | if (done) { 469 | break; 470 | } 471 | result.set(value, bytesRead); 472 | bytesRead += value.length; 473 | } 474 | return result; 475 | } 476 | 477 | async function getSubscribers(newsletterId: string, db: D1Database): Promise<{ email: string }[]> { 478 | const { results } = await db.prepare(`SELECT email FROM Subscriber WHERE newsletter_id = ? AND isSubscribed = true`).bind(newsletterId).all(); 479 | return results as { email: string }[]; 480 | } 481 | 482 | export default { 483 | fetch: app.fetch, 484 | async email(message: ForwardableEmailMessage, env: Bindings, ctx: ExecutionContext) { 485 | const allowedEmails = env.ALLOWED_EMAILS.split(','); 486 | 487 | if (allowedEmails.indexOf(message.from) === -1) { 488 | message.setReject("Address not allowed"); 489 | return; 490 | } 491 | 492 | const subject = message.headers.get('subject') ?? ''; 493 | 494 | console.log(`Processing email with subject: ${subject}`); 495 | 496 | const newsletterIdMatch = subject.match(/\[Newsletter-ID:([a-f0-9-]{36})\]/); 497 | const newsletterId = newsletterIdMatch ? newsletterIdMatch[1] : null; 498 | 499 | const realSubject = subject.replace(/\[Newsletter-ID:[a-f0-9-]{36}\]/, '').trim(); 500 | 501 | if (!newsletterId) { 502 | message.setReject("No Newsletter ID found in subject"); 503 | return; 504 | } 505 | 506 | const rawEmail = await streamToArrayBuffer(message.raw, message.rawSize); 507 | const parser = new PostalMime(); 508 | const parsedEmail = await parser.parse(rawEmail); 509 | if (!parsedEmail.html || !parsedEmail.text) { 510 | console.error(`Can not parse email`); 511 | return; 512 | } 513 | 514 | const fileName = `newsletters/${newsletterId}/${Date.now()}.html`; 515 | 516 | await env.R2.put(fileName, parsedEmail.html); 517 | 518 | const subscribers = await getSubscribers(newsletterId, env.DB); 519 | 520 | for (const subscriber of subscribers) { 521 | await env.QUEUE.send({ 522 | email: subscriber.email, 523 | newsletterId, 524 | subject: realSubject, 525 | fileName 526 | }); 527 | } 528 | }, 529 | async queue(batch: MessageBatch<{ email: string, newsletterId: string, subject: string, fileName: string }>, env: Bindings): Promise { 530 | for (const message of batch.messages) { 531 | const { email, subject, newsletterId, fileName } = message.body; 532 | 533 | console.log(`Sending email to ${email} for newsletter ${newsletterId}`); 534 | 535 | try { 536 | const object = await env.R2.get(fileName); 537 | if (!object) throw new Error('Failed to get HTML content from R2'); 538 | 539 | const htmlContent = await object.text(); 540 | 541 | await sendEmail(env, email, subject, '', htmlContent); 542 | console.log(`Email sent to ${email}`); 543 | } catch (error) { 544 | console.error(`Failed to send email to ${email}:`, error); 545 | } 546 | } 547 | } 548 | } 549 | -------------------------------------------------------------------------------- /app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "ESNext", 5 | "moduleResolution": "Bundler", 6 | "strict": true, 7 | "skipLibCheck": true, 8 | "lib": [ 9 | "ESNext" 10 | ], 11 | "types": [ 12 | "@cloudflare/workers-types" 13 | ], 14 | "jsx": "react-jsx", 15 | "jsxImportSource": "hono/jsx" 16 | }, 17 | } -------------------------------------------------------------------------------- /app/wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "i365-letter-drop" 2 | node_compat = true 3 | compatibility_date = "2023-12-01" 4 | 5 | # [vars] 6 | # ALLOWED_EMAILS = "a@b.com,c@d.com" 7 | 8 | services = [ 9 | { binding = "NOTIFICATION", service = "my-invest-notification", environment = "production" } 10 | ] 11 | 12 | [[queues.producers]] 13 | binding = "QUEUE" 14 | queue = "i365-letter-drop" 15 | 16 | [[queues.consumers]] 17 | queue = "i365-letter-drop" 18 | max_batch_size = 10 19 | max_batch_timeout = 30 20 | max_retries = 10 21 | dead_letter_queue = "i365-letter-drop-dlq" 22 | 23 | [[kv_namespaces]] 24 | binding = "KV" 25 | id = "58ce70465f394f369376c6a3af439d77" 26 | 27 | [[r2_buckets]] 28 | binding = "R2" 29 | bucket_name = "i365-letter-drop" 30 | 31 | [[d1_databases]] 32 | binding = "DB" 33 | database_name = "i365-letter-drop-prod" 34 | database_id = "7ba535ae-d6c7-4570-854d-2483970acd10" 35 | 36 | # [ai] 37 | # binding = "AI" --------------------------------------------------------------------------------