├── .env.dist ├── .eslintrc.json ├── .github └── FUNDING.yml ├── .gitignore ├── LICENSE ├── README.md ├── lib ├── auth.ts ├── db.ts ├── email.ts ├── models │ ├── admin.ts │ ├── campaigns.ts │ ├── settings.ts │ ├── subscriber.ts │ └── templates.ts └── templates │ ├── confirmation.ts │ ├── tracking-pixel.ts │ ├── unsubscribe.ts │ └── welcome.ts ├── mailer.json ├── next.config.js ├── package.json ├── postcss.config.js ├── public └── favicon.ico ├── src ├── components │ ├── BrowserMockup.module.css │ ├── BrowserMockup.tsx │ ├── ImportForm.tsx │ ├── NewSubscribers.tsx │ ├── NewsletterSettings.tsx │ └── NumberCard.tsx ├── pages │ ├── _app.tsx │ ├── _document.tsx │ ├── api │ │ ├── accounts.ts │ │ ├── admin.ts │ │ ├── auth │ │ │ └── [...nextauth].js │ │ ├── campaigns.ts │ │ ├── confirm.ts │ │ ├── dashboard.ts │ │ ├── import.ts │ │ ├── link │ │ │ └── [[...slug]].tsx │ │ ├── pixel │ │ │ └── [[...slug]].tsx │ │ ├── send.ts │ │ ├── settings.ts │ │ ├── subscribe.ts │ │ ├── subscribers.ts │ │ ├── templates.ts │ │ ├── testConnection.ts │ │ ├── testEmail.ts │ │ ├── track.ts │ │ └── unsubscribe.ts │ ├── app │ │ ├── Layout.tsx │ │ ├── campaigns │ │ │ ├── index.tsx │ │ │ └── new.tsx │ │ ├── index.tsx │ │ ├── settings.tsx │ │ ├── subscribers.tsx │ │ └── templates.tsx │ ├── confirm-success.tsx │ ├── index.tsx │ └── setup.tsx ├── styles │ ├── Templates.module.css │ └── globals.css └── utils │ ├── apiMiddleware.ts │ ├── campaign.ts │ ├── fetcher.ts │ └── updater.ts ├── tsconfig.json └── yarn.lock /.env.dist: -------------------------------------------------------------------------------- 1 | MONGODB_URI=mongodb+srv://your-uri 2 | 3 | BASE_URL=http://localhost:3000 # where you host open mailer 4 | NEXTAUTH_URL=http://localhost:3000 5 | AUTH_SECRET=random-secret 6 | 7 | CORS_ORIGIN=* # from where you want to allow signups 8 | API_KEY=random-key # eg. an uuid: https://www.uuidgenerator.net/ 9 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | ko_fi: wweb_dev 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env*.local 29 | 30 | # vercel 31 | .vercel 32 | 33 | # typescript 34 | *.tsbuildinfo 35 | next-env.d.ts 36 | import.csv -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Vincent Will 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 |
2 | 3 | OpenMailer logo 4 | 5 |

OpenMailer is a minimalist Next.js alternative to Mailchimp, Beehiiv, Convertkit etc...

6 |

7 | Contributors 8 | Forks 9 | Stars 10 | Licence 11 | Issues 12 | Languages 13 | Repository Size 14 |

15 | 16 | Screenshots of different pages of OpenMailer 17 |
18 | 19 | ## Features 20 | 21 | - creation of multiple email lists 22 | 23 | - sending emails either via credentials or AWS SES using nodemailer 24 | 25 | - a POST endpoint for subscribing 26 | 27 | - import functionality for subscribers 28 | 29 | - double opt-in and unsubscribe logic 30 | 31 | - sending plain HTML campaigns 32 | 33 | - click & open rate tracking per campaing & subscriber 34 | 35 | - click statistics for links 36 | 37 | ## Setup 38 | 39 | *Disclaimer: Hosting on Vercel does not work properly at the moment because of long running background tasks* 40 | 41 | 1. Create a MongoDB database. Your database user should have admin permissions. 42 | 43 | 2. Install dependencies with `npm i` or `yarn` 44 | 45 | 3. Copy `.env.dist` to `.env.local` and update the configuration 46 | 47 | 4. Run locally with `npm run dev`. To run the production version use `npm run build` and `npm run start`. 48 | 49 | If you need additional instructions, here is a guide on how to self-host applications on an Ubuntu server: [https://dev.to/vincenius/self-hosting-your-web-app-on-an-ubuntu-server-1ple](https://dev.to/vincenius/self-hosting-your-web-app-on-an-ubuntu-server-1ple) 50 | -------------------------------------------------------------------------------- /lib/auth.ts: -------------------------------------------------------------------------------- 1 | import { NextApiResponse } from 'next' 2 | import { getServerSession } from "next-auth/next" 3 | import { authOptions } from "../src/pages/api/auth/[...nextauth]" 4 | import { CustomRequest } from './db' 5 | 6 | const withAuth = async ( 7 | req: CustomRequest, 8 | res: NextApiResponse, 9 | handler: (req: CustomRequest, res: NextApiResponse) => Promise 10 | ) => { 11 | const session = await getServerSession(req, res, authOptions) 12 | 13 | if (session) { 14 | await handler(req, res) 15 | } else { 16 | res.status(401).json({ message: 'Unauthorized' }) 17 | } 18 | } 19 | 20 | export default withAuth -------------------------------------------------------------------------------- /lib/db.ts: -------------------------------------------------------------------------------- 1 | import { MongoClient, Db } from 'mongodb'; 2 | import { NextApiRequest, NextApiResponse } from 'next'; 3 | import { AdminDAO } from './models/admin' 4 | import { SettingsDAO } from './models/settings'; 5 | import { TemplatesDAO } from './models/templates'; 6 | 7 | export interface CustomRequest extends NextApiRequest { 8 | dbClient: MongoClient; 9 | db: Db, 10 | } 11 | 12 | if (!process.env.MONGODB_URI) { 13 | throw new Error('Invalid/Missing environment variable: "MONGODB_URI"') 14 | } 15 | 16 | const uri = process.env.MONGODB_URI || '' 17 | const options = {} 18 | 19 | 20 | const withMongoDB = ( 21 | handler: (req: CustomRequest, res: NextApiResponse) => Promise, 22 | databaseName?: string, 23 | ) => { 24 | return async (req: NextApiRequest, res: NextApiResponse) => { 25 | let mongoClient 26 | try { 27 | const database = databaseName || req.headers['x-mailing-list']?.toString() 28 | const client = new MongoClient(uri, options); 29 | mongoClient= await client.connect(); 30 | const db = client.db(database); 31 | 32 | // Augment the request object with the MongoDB client and database 33 | const customReq: CustomRequest = Object.assign(req, { 34 | dbClient: client, 35 | db, 36 | }); 37 | 38 | await handler(customReq, res); 39 | } catch (error) { 40 | console.error('Error occurred:', error); 41 | res.status(500).json({ error: 'Internal Server Error' }); 42 | } finally { 43 | if (mongoClient) { 44 | mongoClient.close() 45 | } 46 | } 47 | }; 48 | }; 49 | 50 | export const listExists = async (req: NextApiRequest, res: NextApiResponse, listName: string) => { 51 | let mongoClient 52 | try { 53 | const client = new MongoClient(uri, options); 54 | mongoClient= await client.connect(); 55 | const db = client.db('settings'); 56 | const adminDAO = new AdminDAO(db) 57 | const settings = await adminDAO.getSettings() 58 | 59 | return settings && !!settings.newsletters.find(newsletter => newsletter.database === listName) 60 | } catch (error) { 61 | console.error('Error occurred:', error); 62 | res.status(500).json({ error: 'Internal Server Error' }); 63 | } finally { 64 | if (mongoClient) { 65 | mongoClient.close() 66 | } 67 | } 68 | } 69 | 70 | export const getSettings = async (listName: string) => { 71 | let mongoClient 72 | try { 73 | const client = new MongoClient(uri, options); 74 | mongoClient= await client.connect(); 75 | const db = client.db('settings'); 76 | const adminDAO = new AdminDAO(db) 77 | 78 | const newsletterDb = client.db(listName); 79 | const settingsDAO = new SettingsDAO(newsletterDb) 80 | const templatesDAO = new TemplatesDAO(newsletterDb) 81 | 82 | const [newsletterSettings, settings, templates] = await Promise.all([ 83 | settingsDAO.getAll({}), 84 | adminDAO.getSettings(), 85 | templatesDAO.getAll({}), 86 | ]) 87 | 88 | return { 89 | ...settings, 90 | ...newsletterSettings[0], 91 | templates, 92 | } 93 | } catch (error) { 94 | console.error('Error occurred:', error); 95 | return null 96 | } finally { 97 | if (mongoClient) { 98 | mongoClient.close() 99 | } 100 | } 101 | } 102 | 103 | export default withMongoDB; 104 | -------------------------------------------------------------------------------- /lib/email.ts: -------------------------------------------------------------------------------- 1 | import AWS from 'aws-sdk' 2 | import nodemailer, { SentMessageInfo } from 'nodemailer' 3 | import { getPixelHtml } from './templates/tracking-pixel' 4 | import getUnsubscribe from './templates/unsubscribe' 5 | import { getSettings } from './db' 6 | 7 | type TemplateProps = { 8 | userId: string; 9 | templateId: string; 10 | list: string; 11 | }; 12 | 13 | type ConfirmProps = { 14 | list: string; 15 | confirmationId: string; 16 | }; 17 | 18 | const getTransporter = (settings: any) => { 19 | let transporter 20 | if (settings.ses_key && settings.ses_secret) { 21 | AWS.config.update({ 22 | apiVersion: '2010-12-01', 23 | accessKeyId: settings.ses_key, 24 | secretAccessKey: settings.ses_secret, 25 | region: settings.ses_region, 26 | }); 27 | 28 | transporter = nodemailer.createTransport({ 29 | SES: new AWS.SES() 30 | }) 31 | } else { 32 | transporter = nodemailer.createTransport({ 33 | host: settings.email_host, 34 | port: 465, 35 | secure: true, 36 | auth: { 37 | user: settings.email, 38 | pass: settings.email_pass, 39 | }, 40 | } as nodemailer.TransportOptions); 41 | } 42 | return transporter 43 | } 44 | 45 | 46 | const mapLinks = (mjml: string, userId: string, campaignId: string, list: string) => { 47 | let updatedMjml = mjml; 48 | const regex = /href="([^"]+)"/g; 49 | let match; 50 | 51 | while ((match = regex.exec(mjml)) !== null) { 52 | const mappedLink = `href="${process.env.BASE_URL}/api/link/${btoa(userId)}/${btoa(campaignId)}/${btoa(match[1])}/${btoa(list)}"` 53 | updatedMjml = updatedMjml.replace(match[0], mappedLink); 54 | } 55 | 56 | return updatedMjml; 57 | } 58 | 59 | const attachUnsubscribeAndPixel = (html: string, unsubscribeLink: string, trackingPixel: string) => { 60 | let result = html 61 | if (html.includes('')) { 62 | result = html.replace('', `${unsubscribeLink}${trackingPixel}`) 63 | } else { 64 | result = `${html}${unsubscribeLink}${trackingPixel}` 65 | } 66 | 67 | return result 68 | } 69 | 70 | export const sendConfirmationEmail = async (to: string, props: ConfirmProps) => { 71 | const link = `${process.env.BASE_URL}/api/confirm?id=${props.confirmationId}&list=${props.list}` 72 | const settings = await getSettings(props.list) 73 | const template = settings?.templates.find(template => template.name === 'confirmation') 74 | const subject = template?.subject || 'Confirm your email address' 75 | const regex = new RegExp('{{CONFIRMATION_LINK}}', 'g'); 76 | const html = (template?.html || '').replace(regex, link) 77 | 78 | return sendEmail(to, subject, html, settings) 79 | } 80 | 81 | export const sendCampaign = async (to: string, subject: string, html: string, props: TemplateProps) => { 82 | const settings = await getSettings(props.list) 83 | const injectedLinksHtml = mapLinks(html, props.userId, props.templateId, props.list) 84 | const trackingPixel = getPixelHtml({ userId: props.userId, emailId: props.templateId, list: props.list }) 85 | const unsubscribeLink = getUnsubscribe({ userId: props.userId, list: props.list }) 86 | const finalHtml = attachUnsubscribeAndPixel(injectedLinksHtml, unsubscribeLink, trackingPixel) 87 | 88 | return sendEmail(to, subject, finalHtml, settings) 89 | } 90 | 91 | export const sendWelcomeEmail = async (to: string, list: string, userId: string) => { 92 | const settings = await getSettings(list) 93 | const template = settings?.templates.find(template => template.name === 'welcome') 94 | 95 | if (template) { 96 | const subject = template?.subject || 'Confirm your email address' 97 | const unsubscribeLink = getUnsubscribe({ userId, list }) 98 | const finalHtml = attachUnsubscribeAndPixel(template?.html || '', unsubscribeLink, '') 99 | 100 | return sendEmail(to, subject, finalHtml, settings) 101 | } 102 | } 103 | 104 | const sendEmail = async (to: string, subject: string, html: string, settings: any) => { 105 | try { 106 | const transporter = getTransporter(settings) 107 | const result: SentMessageInfo = await transporter.sendMail({ 108 | from: `${settings.name} <${settings.email}>`, 109 | to: `${to} <${to}>`, 110 | subject, 111 | html, 112 | }) 113 | 114 | if (!settings.ses_key || !settings.ses_secret) { 115 | if (result.accepted[0]) { 116 | // console.info('Successfully send email:', subject, result); 117 | return 'success'; 118 | } else if (result.rejected[0]) { 119 | console.error('Failed to send email:', subject, result); 120 | return 'rejected'; 121 | } else { 122 | console.error('Unexpected error on sending email:', subject, result); 123 | return 'errored'; 124 | } 125 | } 126 | 127 | return 'success' 128 | } catch (err) { 129 | console.error('Unexpected error on sending email:', subject, err); 130 | return 'errored' 131 | } 132 | } 133 | 134 | export default sendEmail -------------------------------------------------------------------------------- /lib/models/admin.ts: -------------------------------------------------------------------------------- 1 | import { Db, Collection, ObjectId, WithId } from 'mongodb'; 2 | 3 | export interface Account { 4 | _id?: ObjectId, 5 | username: string, 6 | password: string, 7 | role: string, // admin (maybe restricted roles added in future) 8 | } 9 | 10 | export interface Newsletter { 11 | _id?: ObjectId, 12 | name: string, 13 | database: string, 14 | } 15 | 16 | export interface Settings { 17 | _id?: ObjectId, 18 | newsletters: Newsletter[], 19 | } 20 | 21 | export class AdminDAO { 22 | // change to admin with accounts & settings collections 23 | private settingsCollection: Collection; 24 | private accountsCollection: Collection; 25 | 26 | constructor(db: Db) { 27 | this.settingsCollection = db.collection('settings'); 28 | this.accountsCollection = db.collection('accounts'); 29 | } 30 | 31 | async getSettings(): Promise { 32 | const result = await this.settingsCollection.find().toArray() 33 | return result[0]; 34 | } 35 | 36 | async createSettings(settings: Settings): Promise { 37 | await this.settingsCollection.insertOne(settings); 38 | } 39 | 40 | async updateSettings(query: Object, update: Object): Promise | null> { 41 | const result = await this.settingsCollection.findOneAndUpdate( 42 | query, 43 | { $set: update }, 44 | { returnDocument: 'after' } 45 | ) 46 | return result; 47 | } 48 | 49 | async getAllByQuery(query: Object): Promise { 50 | return await this.accountsCollection.find(query).toArray(); 51 | } 52 | 53 | async createAccount(user: Account): Promise { 54 | await this.accountsCollection.insertOne(user); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /lib/models/campaigns.ts: -------------------------------------------------------------------------------- 1 | import { Db, Collection, ObjectId, WithId } from 'mongodb'; 2 | 3 | export interface User { 4 | id: string, 5 | status: string, 6 | opens: number, 7 | clicks: string[], 8 | } 9 | 10 | export interface Campaign { 11 | _id?: ObjectId, 12 | id: string, 13 | createdAt: Date; 14 | subject: string; 15 | html: string; 16 | users: User[]; 17 | } 18 | 19 | export class CampaignDAO { 20 | private collection: Collection; 21 | 22 | constructor(db: Db) { 23 | this.collection = db.collection('campaigns'); 24 | } 25 | 26 | async getAll(): Promise { 27 | return await this.collection.find().toArray(); 28 | } 29 | 30 | async getByQuery(query: Object): Promise { 31 | const result = await this.collection.find(query).toArray() 32 | return result[0]; 33 | } 34 | 35 | async getLatest(): Promise { 36 | const result = await this.collection.findOne({}, {sort: {createdAt: -1}}) 37 | return result 38 | } 39 | 40 | async addUserByQuery(query: Object, update: User): Promise | null> { 41 | const result = await this.collection.findOneAndUpdate( 42 | query, 43 | { $push: { users: update } }, 44 | { returnDocument: 'after' } 45 | ) 46 | return result; 47 | } 48 | 49 | async trackOpen(campaignId: string, userId: string): Promise | null> { 50 | const result = await this.collection.findOneAndUpdate( 51 | { 52 | id: campaignId, 53 | 'users.id': userId, 54 | }, 55 | { $inc: { 'users.$.opens': 1 }}, 56 | { returnDocument: 'after' } 57 | ) 58 | return result; 59 | } 60 | 61 | async trackClick(campaignId: string, userId: string, link: string): Promise | null> { 62 | console.log('TRACK', userId, link) 63 | const result = await this.collection.findOneAndUpdate( 64 | { 65 | id: campaignId, 66 | 'users.id': userId, 67 | }, 68 | { $push: { 'users.$.clicks': link }}, 69 | { returnDocument: 'after' } 70 | ) 71 | return result; 72 | } 73 | 74 | async updateStatus(campaignId: string, userId: string, status: string): Promise | null> { 75 | const result = await this.collection.findOneAndUpdate( 76 | { 77 | id: campaignId, 78 | 'users.id': userId, 79 | }, 80 | { $set: { 'users.$.status': status } }, 81 | { returnDocument: 'after' } 82 | ) 83 | return result; 84 | } 85 | 86 | // async updateUserByQuery(campaignId: string, userId: string, update: Object): Promise | null> { 87 | // const mappedUpdate = Object.entries(update).reduce((acc, [key, value]) => ({ 88 | // ...acc, 89 | // [`users.$.${key}`]: value, 90 | // })) 91 | // console.log('DBG', mappedUpdate) 92 | // const result = await this.collection.findOneAndUpdate( 93 | // { 94 | // id: campaignId, 95 | // 'users.id': userId, 96 | // }, 97 | // { $set: mappedUpdate }, 98 | // { returnDocument: 'after' } 99 | // ) 100 | // return result; 101 | // } 102 | 103 | async create(user: Campaign): Promise { 104 | await this.collection.insertOne(user); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /lib/models/settings.ts: -------------------------------------------------------------------------------- 1 | import { Db, Collection, WithId, ObjectId } from 'mongodb'; 2 | 3 | export interface Settings { 4 | _id?: ObjectId, 5 | name: string, 6 | email: string, 7 | api_key: string, 8 | database: string, 9 | sending_type: string, // 'email' or 'ses' 10 | ses_key?: string, 11 | ses_secret?: string, 12 | ses_region?: string, 13 | email_pass?: string, 14 | email_host?: string, 15 | } 16 | 17 | export class SettingsDAO { 18 | private collection: Collection; 19 | 20 | constructor(db: Db) { 21 | this.collection = db.collection('settings'); 22 | } 23 | 24 | async getAll(query: Object): Promise { 25 | return await this.collection.find(query).toArray(); 26 | } 27 | 28 | async getByQuery(query: Object): Promise { 29 | const result = await this.collection.find(query).toArray() 30 | return result[0]; 31 | } 32 | 33 | async updateByQuery(query: Object, update: Object): Promise | null> { 34 | const result = await this.collection.findOneAndUpdate( 35 | query, 36 | { $set: update }, 37 | { returnDocument: 'after' } 38 | ) 39 | return result; 40 | } 41 | 42 | async create(settings: Settings): Promise { 43 | await this.collection.insertOne(settings); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /lib/models/subscriber.ts: -------------------------------------------------------------------------------- 1 | import { Db, Collection, WithId, ObjectId } from 'mongodb'; 2 | 3 | export interface Subscriber { 4 | _id?: ObjectId, 5 | email: string; 6 | createdAt: Date; 7 | name: string | null; 8 | groups: string[]; 9 | confirmed: boolean; 10 | confirmationId: string, 11 | received: number, 12 | opened: number, 13 | clicked: number, 14 | location?: string; 15 | unsubscribedAt?: Date, 16 | } 17 | 18 | export class SubscriberDAO { 19 | private collection: Collection; 20 | 21 | constructor(db: Db) { 22 | this.collection = db.collection('subscribers'); 23 | } 24 | 25 | async getAll(query: Object, page?: number): Promise { 26 | let cursor = this.collection.find(query).sort({ createdAt: -1 }); 27 | 28 | if (page && 50) { 29 | const skipAmount = (page - 1) * 50; 30 | cursor = cursor.skip(skipAmount).limit(50); 31 | } 32 | 33 | return await cursor.toArray(); 34 | } 35 | 36 | async getByQuery(query: Object): Promise { 37 | const result = await this.collection.find(query).toArray() 38 | return result[0]; 39 | } 40 | 41 | async getCount(query: Object): Promise { 42 | return await this.collection.countDocuments(query); 43 | } 44 | 45 | async updateByQuery(query: Object, update: Object): Promise | null> { 46 | const result = await this.collection.findOneAndUpdate( 47 | query, 48 | { $set: update }, 49 | { returnDocument: 'after' } 50 | ) 51 | return result; 52 | } 53 | 54 | async deleteByQuery (query: Object): Promise | null> { 55 | const result = await this.collection.findOneAndDelete(query) 56 | return result; 57 | } 58 | 59 | async increaseTrack(query: Object, field: string): Promise | null> { 60 | const result = await this.collection.findOneAndUpdate( 61 | query, 62 | { $inc: { [field]: 1 } }, 63 | { returnDocument: 'after' } 64 | ) 65 | return result; 66 | } 67 | 68 | async create(user: Subscriber): Promise { 69 | await this.collection.insertOne(user); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /lib/models/templates.ts: -------------------------------------------------------------------------------- 1 | import { Db, Collection, WithId, ObjectId } from 'mongodb'; 2 | 3 | export interface Templates { 4 | _id?: ObjectId, 5 | name: string, 6 | subject: string, 7 | html: string, 8 | } 9 | 10 | export class TemplatesDAO { 11 | private collection: Collection; 12 | 13 | constructor(db: Db) { 14 | this.collection = db.collection('templates'); 15 | } 16 | 17 | async getAll(query: Object): Promise { 18 | return await this.collection.find(query).toArray(); 19 | } 20 | 21 | async getByQuery(query: Object): Promise { 22 | const result = await this.collection.find(query).toArray() 23 | return result[0]; 24 | } 25 | 26 | async updateByQuery(query: Object, update: Object): Promise | null> { 27 | const result = await this.collection.findOneAndUpdate( 28 | query, 29 | { $set: update }, 30 | { returnDocument: 'after' } 31 | ) 32 | return result; 33 | } 34 | 35 | async create(Templates: Templates): Promise { 36 | await this.collection.insertOne(Templates); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /lib/templates/confirmation.ts: -------------------------------------------------------------------------------- 1 | export const getHtml = (name: string) => { 2 | return ` 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 40 | 50 | 57 | 58 | 66 | 72 | 73 | 74 | 75 | 76 |
77 | 78 |
79 | 80 | 81 | 82 | 124 | 125 | 126 |
83 | 84 |
85 | 86 | 87 | 88 | 91 | 92 | 93 | 94 | 97 | 98 | 99 | 100 | 105 | 106 | 107 | 108 | 111 | 112 | 113 | 118 | 119 | 120 |
89 |

Verify your E-Mail

90 |
95 |
Thanks for your interest in ${name}!
96 |
101 |
102 | To start receiving the newsletter at this email address, confirm your subscription by clicking the link below 103 |
104 |
109 | 110 |
114 |
115 | If you feel this email reached you in error or you didn't sign up for ${name} please disregard this email. 116 |
117 |
121 |
122 | 123 |
127 |
128 | 129 |
130 | 131 | 132 | ` 133 | } 134 | 135 | export const getSubject = (name: string) => `${name} | Confirm your email address` -------------------------------------------------------------------------------- /lib/templates/tracking-pixel.ts: -------------------------------------------------------------------------------- 1 | export type TrackingProps = { 2 | userId: string; 3 | emailId: string; 4 | list: string; 5 | }; 6 | 7 | const getImage = ({ userId = '', emailId = '', list = '' }: TrackingProps) => { 8 | return `` 9 | } 10 | 11 | export const getPixelHtml = ({ userId = '', emailId = '', list = '' }: TrackingProps) => { 12 | return `` 17 | } 18 | 19 | export default getImage 20 | -------------------------------------------------------------------------------- /lib/templates/unsubscribe.ts: -------------------------------------------------------------------------------- 1 | export type TrackingProps = { 2 | userId: string; 3 | list: string; 4 | }; 5 | 6 | const getUnsubscribe = ({ userId = '', list = '' }: TrackingProps) => { 7 | return `
8 | 9 | 10 | 11 | 27 | 28 | 29 |
12 | 13 |
14 | 15 | 16 | 17 | 20 | 21 | 22 |
18 | 19 |
23 |
24 | 25 | 26 |
30 |
` 31 | } 32 | 33 | export default getUnsubscribe 34 | -------------------------------------------------------------------------------- /lib/templates/welcome.ts: -------------------------------------------------------------------------------- 1 | import getTrackingPixel from './tracking-pixel' 2 | 3 | export type EmailProps = { 4 | userId: string; 5 | list: string; 6 | }; 7 | 8 | export const campaignId = 'welcome' 9 | export const subject = '🚀 Welcome to WebDev Town! Your Weekly Web Dev Newsletter 💻' 10 | 11 | const getEmail = ({ userId, list }: EmailProps) => { 12 | return ` 13 | 14 | 15 | 16 | 17 | 18 | a { color: #017a8c; } .bg > table { background-image: linear-gradient(158deg, rgba(84, 84, 84, 0.03) 0%, rgba(84, 84, 84, 0.03) 20%,rgba(219, 219, 219, 0.03) 20%, rgba(219, 219, 219, 0.03) 40%,rgba(54, 54, 54, 0.03) 40%, rgba(54, 54, 54, 0.03) 60%,rgba(99, 99, 99, 0.03) 60%, rgba(99, 99, 99, 0.03) 80%,rgba(92, 92, 92, 0.03) 80%, rgba(92, 92, 92, 0.03) 100%),linear-gradient(45deg, rgba(221, 221, 221, 0.02) 0%, rgba(221, 221, 221, 0.02) 14.286%,rgba(8, 8, 8, 0.02) 14.286%, rgba(8, 8, 8, 0.02) 28.572%,rgba(52, 52, 52, 0.02) 28.572%, rgba(52, 52, 52, 0.02) 42.858%,rgba(234, 234, 234, 0.02) 42.858%, rgba(234, 234, 234, 0.02) 57.144%,rgba(81, 81, 81, 0.02) 57.144%, rgba(81, 81, 81, 0.02) 71.42999999999999%,rgba(239, 239, 239, 0.02) 71.43%, rgba(239, 239, 239, 0.02) 85.71600000000001%,rgba(187, 187, 187, 0.02) 85.716%, rgba(187, 187, 187, 0.02) 100.002%),linear-gradient(109deg, rgba(33, 33, 33, 0.03) 0%, rgba(33, 33, 33, 0.03) 12.5%,rgba(147, 147, 147, 0.03) 12.5%, rgba(147, 147, 147, 0.03) 25%,rgba(131, 131, 131, 0.03) 25%, rgba(131, 131, 131, 0.03) 37.5%,rgba(151, 151, 151, 0.03) 37.5%, rgba(151, 151, 151, 0.03) 50%,rgba(211, 211, 211, 0.03) 50%, rgba(211, 211, 211, 0.03) 62.5%,rgba(39, 39, 39, 0.03) 62.5%, rgba(39, 39, 39, 0.03) 75%,rgba(55, 55, 55, 0.03) 75%, rgba(55, 55, 55, 0.03) 87.5%,rgba(82, 82, 82, 0.03) 87.5%, rgba(82, 82, 82, 0.03) 100%),linear-gradient(348deg, rgba(42, 42, 42, 0.02) 0%, rgba(42, 42, 42, 0.02) 20%,rgba(8, 8, 8, 0.02) 20%, rgba(8, 8, 8, 0.02) 40%,rgba(242, 242, 242, 0.02) 40%, rgba(242, 242, 242, 0.02) 60%,rgba(42, 42, 42, 0.02) 60%, rgba(42, 42, 42, 0.02) 80%,rgba(80, 80, 80, 0.02) 80%, rgba(80, 80, 80, 0.02) 100%),linear-gradient(120deg, rgba(106, 106, 106, 0.03) 0%, rgba(106, 106, 106, 0.03) 14.286%,rgba(67, 67, 67, 0.03) 14.286%, rgba(67, 67, 67, 0.03) 28.572%,rgba(134, 134, 134, 0.03) 28.572%, rgba(134, 134, 134, 0.03) 42.858%,rgba(19, 19, 19, 0.03) 42.858%, rgba(19, 19, 19, 0.03) 57.144%,rgba(101, 101, 101, 0.03) 57.144%, rgba(101, 101, 101, 0.03) 71.42999999999999%,rgba(205, 205, 205, 0.03) 71.43%, rgba(205, 205, 205, 0.03) 85.71600000000001%,rgba(53, 53, 53, 0.03) 85.716%, rgba(53, 53, 53, 0.03) 100.002%),linear-gradient(45deg, rgba(214, 214, 214, 0.03) 0%, rgba(214, 214, 214, 0.03) 16.667%,rgba(255, 255, 255, 0.03) 16.667%, rgba(255, 255, 255, 0.03) 33.334%,rgba(250, 250, 250, 0.03) 33.334%, rgba(250, 250, 250, 0.03) 50.001000000000005%,rgba(231, 231, 231, 0.03) 50.001%, rgba(231, 231, 231, 0.03) 66.668%,rgba(241, 241, 241, 0.03) 66.668%, rgba(241, 241, 241, 0.03) 83.33500000000001%,rgba(31, 31, 31, 0.03) 83.335%, rgba(31, 31, 31, 0.03) 100.002%),linear-gradient(59deg, rgba(224, 224, 224, 0.03) 0%, rgba(224, 224, 224, 0.03) 12.5%,rgba(97, 97, 97, 0.03) 12.5%, rgba(97, 97, 97, 0.03) 25%,rgba(143, 143, 143, 0.03) 25%, rgba(143, 143, 143, 0.03) 37.5%,rgba(110, 110, 110, 0.03) 37.5%, rgba(110, 110, 110, 0.03) 50%,rgba(34, 34, 34, 0.03) 50%, rgba(34, 34, 34, 0.03) 62.5%,rgba(155, 155, 155, 0.03) 62.5%, rgba(155, 155, 155, 0.03) 75%,rgba(249, 249, 249, 0.03) 75%, rgba(249, 249, 249, 0.03) 87.5%,rgba(179, 179, 179, 0.03) 87.5%, rgba(179, 179, 179, 0.03) 100%),linear-gradient(241deg, rgba(58, 58, 58, 0.02) 0%, rgba(58, 58, 58, 0.02) 25%,rgba(124, 124, 124, 0.02) 25%, rgba(124, 124, 124, 0.02) 50%,rgba(254, 254, 254, 0.02) 50%, rgba(254, 254, 254, 0.02) 75%,rgba(52, 52, 52, 0.02) 75%, rgba(52, 52, 52, 0.02) 100%),linear-gradient(90deg, #ffffff,#ffffff); !important }; 19 | 20 | 21 | 22 | 23 | 24 | 25 |

🚀 Welcome Aboard!

26 |
27 |
28 |
29 | 30 | 31 | Hi,
Thank you for subscribing to the WebDev Town Newsletter.
32 |
📅 What to Expect:
33 | Each Wednesday morning, I'll send you an e-mail with the most valuable web development resources right to your inbox. 34 |
📚 Check Out What's Already Here:
35 | Can't wait for the next issue? You can find all previous resources and some useful collections on the website: 36 | 37 | 38 | 🔗All resources from previous issues 39 | 40 | 41 | 🖼️Ultimate Collection Of Websites For Creative Backgrounds 42 | 43 | 44 | 💻A Curated List Of Websites For Free Icon Sets 45 | 46 |
💡 Share Your Knowledge:
47 | Do you have a side project, or a valuable resources you'd like to share with the community? I'd love to hear from you! Just hit "reply" to this email and send your links or feedback. You can also submit your links directly through the online portal. 48 | 49 | Thank you once again. I really do appreciate having you on board.
Feel free to reach out anytime if you have questions, suggestions, or just want to chat about web development.
50 | Cheers,
Vincent
51 | P.S. You can also follow me on Twitter. I'm sharing insights how I'm building and growing this newsletter, along with other side-projects. 52 |
53 |
54 | 55 | 56 | 57 | Unsubscribe 58 | 59 | 60 | ${getTrackingPixel({ userId, emailId: 'welcome', list })} 61 | 62 |
63 |
` 64 | } 65 | 66 | export default getEmail -------------------------------------------------------------------------------- /mailer.json: -------------------------------------------------------------------------------- 1 | { 2 | "apps": [ 3 | { 4 | "name": "mailer", 5 | "script": "npm", 6 | "args": "run start", 7 | "env": { 8 | "NODE_ENV": "production" 9 | } 10 | } 11 | ] 12 | } -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | } 5 | 6 | module.exports = nextConfig 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "open-mailer", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@mantine/code-highlight": "^7.2.2", 13 | "@mantine/core": "^7.2.1", 14 | "@mantine/dates": "^7.2.1", 15 | "@mantine/dropzone": "^7.2.2", 16 | "@mantine/hooks": "^7.2.1", 17 | "@mantine/notifications": "^7.2.1", 18 | "@tabler/icons-react": "^2.39.0", 19 | "@vercel/og": "^0.5.18", 20 | "aws-sdk": "^2.1480.0", 21 | "dayjs": "^1.11.10", 22 | "jsonschema": "^1.4.1", 23 | "mjml": "^4.14.1", 24 | "mongodb": "^6.1.0", 25 | "next": "13.5.4", 26 | "next-auth": "^4.23.2", 27 | "nextjs-cors": "^2.1.2", 28 | "nodemailer": "^6.9.6", 29 | "papaparse": "^5.4.1", 30 | "react": "^18", 31 | "react-dom": "^18", 32 | "recharts": "^2.9.0", 33 | "swr": "^2.2.4", 34 | "uuid": "^9.0.1" 35 | }, 36 | "devDependencies": { 37 | "@types/mjml": "^4.7.2", 38 | "@types/node": "^20.8.5", 39 | "@types/nodemailer": "^6.4.13", 40 | "@types/papaparse": "^5.3.11", 41 | "@types/react": "^18", 42 | "@types/react-dom": "^18", 43 | "@types/uuid": "^9.0.5", 44 | "csv-parse": "^5.5.2", 45 | "eslint": "^8", 46 | "eslint-config-next": "13.5.4", 47 | "postcss": "^8.4.31", 48 | "postcss-preset-mantine": "^1.8.0", 49 | "postcss-simple-vars": "^7.0.1", 50 | "ts-node": "^10.9.1", 51 | "typescript": "^5.2.2" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | 'postcss-preset-mantine': {}, 4 | 'postcss-simple-vars': { 5 | variables: { 6 | 'mantine-breakpoint-xs': '36em', 7 | 'mantine-breakpoint-sm': '48em', 8 | 'mantine-breakpoint-md': '62em', 9 | 'mantine-breakpoint-lg': '75em', 10 | 'mantine-breakpoint-xl': '88em', 11 | }, 12 | }, 13 | }, 14 | }; -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vincenius/OpenMailer/494fee3bcf99900bf13d9a5f9939fc485635d81e/public/favicon.ico -------------------------------------------------------------------------------- /src/components/BrowserMockup.module.css: -------------------------------------------------------------------------------- 1 | .browserHead { 2 | position: sticky; 3 | z-index: 100; 4 | height: 30px; 5 | width: 100%; 6 | top: 0; 7 | background: #e8e8e8; 8 | display: flex; 9 | align-items: center; 10 | } 11 | 12 | .browserDots { 13 | width: 12px; 14 | height: 12px; 15 | border-radius: 12px; 16 | margin: 0 30px; 17 | background-color: #ffbb00; 18 | } 19 | 20 | .browserDots::before, .browserDots::after { 21 | width: 12px; 22 | height: 12px; 23 | border-radius: 12px; 24 | content: " "; 25 | position: absolute; 26 | } 27 | 28 | .browserDots::before { 29 | margin: 0 -18px; 30 | background-color: #ff4f4d; 31 | } 32 | 33 | .browserDots::after { 34 | margin: 0 18px; 35 | background-color: #00ce15; 36 | } -------------------------------------------------------------------------------- /src/components/BrowserMockup.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from 'react' 2 | import { Box, Card } from '@mantine/core' 3 | import classes from './BrowserMockup.module.css' 4 | 5 | type Props = { 6 | children: ReactNode, 7 | } 8 | 9 | const BrowserMockup = (props: Props) => { 10 | return 11 | 12 | 13 |
14 |
15 |
16 |
17 | 18 | {props.children} 19 | 20 |
21 |
22 | } 23 | 24 | export default BrowserMockup 25 | -------------------------------------------------------------------------------- /src/components/ImportForm.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import { Button, Modal, Group, Text, rem, Stepper, Table, Flex, NumberInput, Select, Box } from '@mantine/core' 3 | import { useDisclosure } from '@mantine/hooks'; 4 | import { IconUpload, IconPhoto, IconX } from '@tabler/icons-react'; 5 | import { Dropzone, MIME_TYPES } from '@mantine/dropzone'; 6 | import { notifications } from '@mantine/notifications'; 7 | import Papa from 'papaparse'; 8 | import { useUpdate } from '../utils/apiMiddleware' 9 | 10 | type Props = { 11 | onSuccess?: () => void, 12 | } 13 | 14 | type ImportProps = { 15 | start: number, 16 | emailCol: number, 17 | createdAtCol?: number | null, 18 | receivedCol?: number | null, 19 | openedCol?: number | null, 20 | clickedCol?: number | null, 21 | locationCol?: number | null, 22 | } 23 | 24 | function indexToExcelHeader(index: number): string { 25 | let dividend = index + 1; 26 | let columnName = ''; 27 | 28 | while (dividend > 0) { 29 | let modulo = (dividend - 1) % 26; 30 | columnName = String.fromCharCode(65 + modulo) + columnName; 31 | dividend = parseInt(((dividend - modulo) / 26).toString(), 10); 32 | } 33 | 34 | return columnName; 35 | } 36 | 37 | export default function ImportForm(props: Props) { 38 | const { onSuccess } = props 39 | const [loading, setLoading] = useState(false); 40 | const [progress, setProgress] = useState(0); 41 | const [total, setTotal] = useState(0); 42 | const [active, setActive] = useState(0); 43 | const [data, setData] = useState([]); 44 | const [opened, { open, close }] = useDisclosure(false); 45 | const [importOptions, setImportOptions] = useState({ 46 | start: 1, 47 | emailCol: 0, 48 | }); 49 | const { triggerUpdate } = useUpdate() 50 | 51 | const openModal = () => { 52 | setLoading(false) 53 | setTotal(0) 54 | setActive(0) 55 | setProgress(0) 56 | open() 57 | } 58 | 59 | const parseCsv = (files: any) => { 60 | setLoading(true); 61 | Papa.parse(files[0], { complete: (results: any) => { 62 | setActive(1) 63 | setLoading(false) 64 | setData(results.data) 65 | } }) 66 | } 67 | 68 | const monitorProgress = async (count: number) => { 69 | const { progress: tmpProgress } = await fetch('/api/import', { method: 'GET' }).then(res => res.json()) 70 | setProgress(tmpProgress) 71 | 72 | if (tmpProgress === count) { 73 | setLoading(false) 74 | close() 75 | notifications.show({ 76 | color: 'green', 77 | title: 'Success', 78 | message: `Successfully imported ${count} subscribers...`, 79 | }); 80 | if (onSuccess) { 81 | onSuccess() 82 | } 83 | } else { 84 | await new Promise(resolve => setTimeout(resolve, 1000)) 85 | monitorProgress(count) 86 | } 87 | } 88 | 89 | const startImport = () => { 90 | const copyData = Array.from(data); 91 | copyData.splice(0, (importOptions.start - 1)); 92 | const subscribers = copyData.map(row => ({ 93 | email: row[importOptions.emailCol], 94 | createdAt: importOptions.createdAtCol && row[importOptions.createdAtCol], 95 | received: importOptions.receivedCol && row[importOptions.receivedCol], 96 | opened: importOptions.openedCol && row[importOptions.openedCol], 97 | clicked: importOptions.clickedCol && row[importOptions.clickedCol], 98 | location: importOptions.locationCol && row[importOptions.locationCol], 99 | })).filter(e => !!e.email) 100 | setTotal(subscribers.length) 101 | setLoading(true) 102 | triggerUpdate({ url: '/api/import', method: 'POST', body: subscribers }) 103 | monitorProgress(subscribers.length) 104 | } 105 | 106 | const columns = ((data[0] || []) as String[]).map((elem: any, index: number) => indexToExcelHeader(index)); 107 | 108 | return <> 109 | 115 | 116 | 117 | 118 | 119 | console.log('rejected files', files)} 122 | maxSize={3 * 1024 ** 2} 123 | accept={[MIME_TYPES.csv]} 124 | multiple={false} 125 | loading={loading} 126 | > 127 | 128 | 129 | 133 | 134 | 135 | 139 | 140 | 141 | 145 | 146 | 147 |
148 | 149 | Drag your CSV file here or click to select files 150 | 151 | 152 | Your file should not exceed 5mb. 153 | 154 |
155 |
156 |
157 |
158 | 159 | 160 | 161 | 162 | 163 | { columns.map(c => 164 | {c} 165 | )} 166 | 167 | 168 | { 169 | data.slice(0,5).map((row: Array, i) => ( 170 | 171 | {i+1} 172 | {row.map((column, j) => {column} )} 173 | 174 | )) 175 | } 176 |
177 | {data.length > 5 && 178 | ...{data.length - 5} more rows 179 | } 180 | 181 |
{ 182 | e.preventDefault() 183 | setActive(2) 184 | }}> 185 | 186 | setImportOptions({ ...importOptions, start: parseInt(String(val), 10) })} 193 | defaultValue={1} 194 | /> 195 | ({ value: i.toString(), label: column }))} 210 | onChange={(val) => setImportOptions({ ...importOptions, createdAtCol: val ? parseInt(val) : null })} 211 | defaultValue={importOptions.createdAtCol?.toString()} 212 | mr="md" 213 | /> 214 | 215 | ({ value: i.toString(), label: column }))} 227 | onChange={(val) => setImportOptions({ ...importOptions, openedCol: val ? parseInt(val) : null })} 228 | defaultValue={importOptions.openedCol?.toString()} 229 | /> 230 | 231 | 232 | ({ value: i.toString(), label: column }))} 245 | onChange={(val) => setImportOptions({ ...importOptions, locationCol: val ? parseInt(val) : null })} 246 | defaultValue={importOptions.locationCol?.toString()} 247 | /> 248 | 249 | 250 | 251 | 252 | 253 | 254 |
255 |
256 | 257 | An example import subscriber will look like this: 258 | {active === 2 && 259 | Email: {data[importOptions.start - 1][importOptions.emailCol]} 260 | { importOptions.createdAtCol && Created at: {data[importOptions.start - 1][importOptions.createdAtCol]} } 261 | { importOptions.receivedCol && Emails received: {data[importOptions.start - 1][importOptions.receivedCol]} } 262 | { importOptions.openedCol && Emails opened: {data[importOptions.start - 1][importOptions.openedCol]} } 263 | { importOptions.clickedCol && Emails clicked: {data[importOptions.start - 1][importOptions.clickedCol]} } 264 | { importOptions.locationCol && Location: {data[importOptions.start - 1][importOptions.locationCol]} } 265 | } 266 | 267 | { loading && Importing... ({progress} / {total}) } 268 | 269 | 270 | 271 | 272 | 273 | 274 |
275 |
276 | 277 | } 278 | -------------------------------------------------------------------------------- /src/components/NewSubscribers.tsx: -------------------------------------------------------------------------------- 1 | import { CodeHighlight } from '@mantine/code-highlight'; 2 | import { Card, LoadingOverlay, Tabs } from '@mantine/core' 3 | import { useLocalStorage } from '@mantine/hooks' 4 | 5 | import { useFetch } from '@/utils/apiMiddleware' 6 | 7 | const curlCode = (url: string, list: string) => `curl -X POST -H "Content-Type: application/json" \\ 8 | -d '{"list": "${list}", "email": "subscriber@example.com"}' \\ 9 | ${url}/api/subscribe` 10 | 11 | const jsCode = (url: string, list: string) => `fetch("${url}/api/subscribe", { 12 | method: 'POST', 13 | headers: { 14 | 'Content-Type': 'application/json', 15 | }, 16 | body: JSON.stringify({ 17 | list: '${list}', 18 | email: 'subscriber@example.com', // replace with user email 19 | }), 20 | })` 21 | 22 | const htmlCode = (url: string, list: string) => `
23 |
24 |
25 | 26 | 27 |
` 28 | 29 | 30 | export default function NewSubscribers() { 31 | const { data = {}, error, isLoading } = useFetch('/api/admin') 32 | const [mailingList] = useLocalStorage({ key: 'selected-mailing-list' }); 33 | const { base_url } = data; 34 | 35 | return (<> 36 | 37 | 38 | 39 | 40 | 41 | JavaScript 42 | 43 | 44 | HTML Form 45 | 46 | 47 | cURL 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | ) 65 | } -------------------------------------------------------------------------------- /src/components/NewsletterSettings.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import useSWR from 'swr' 3 | import { useDisclosure, useLocalStorage } from '@mantine/hooks' 4 | import { notifications } from '@mantine/notifications'; 5 | import { Text, TextInput, Flex, Button, SegmentedControl, Popover, Modal } from '@mantine/core' 6 | import { useUpdate } from '../utils/apiMiddleware' 7 | import fetcher from '../utils/fetcher' 8 | import { Settings } from '../../lib/models/settings' 9 | 10 | type Props = { 11 | loading: boolean, 12 | setLoading: (loading: boolean) => void, 13 | onSuccess?: (database?: string) => void, 14 | isUpdate?: boolean, 15 | defaultValues?: Settings, 16 | buttonCaption?: string, 17 | } 18 | 19 | const NewsetterSettings = (props: Props) => { 20 | const { loading, setLoading } = props 21 | const [formValues, setFormValues] = useState(!props.isUpdate 22 | ? { sending_type: 'ses' } 23 | : props.defaultValues) 24 | const { triggerUpdate } = useUpdate() 25 | const [opened, { open, close }] = useDisclosure(false); 26 | const { data: { newsletters = [] } = {}, mutate } = useSWR('/api/admin', fetcher) 27 | const [mailingList, setMailingList] = useLocalStorage({ key: 'selected-mailing-list' }); 28 | 29 | const handleSubmit = async (e: React.SyntheticEvent) => { 30 | e.preventDefault(); 31 | setLoading(true) 32 | let database 33 | 34 | if (props.isUpdate) { 35 | await triggerUpdate({ url: '/api/settings', method: 'PUT', body: formValues }) 36 | } else { 37 | const { message } = await triggerUpdate({ url: '/api/admin', method: 'PUT', body: { name: formValues.name }}) 38 | database = message 39 | 40 | await triggerUpdate({ url: '/api/settings', method: 'POST', body: { ...formValues, database } }) 41 | } 42 | 43 | if (props.onSuccess) { 44 | props.onSuccess(database) 45 | } 46 | setLoading(false) 47 | } 48 | 49 | const handleDelete = async () => { 50 | setLoading(true) 51 | 52 | await triggerUpdate({ url: '/api/settings', method: 'DELETE', body: formValues }) 53 | const newSelected = newsletters.filter((n: any) => n.database !== formValues.database)[0] 54 | mutate() 55 | setMailingList(newSelected?.database) 56 | close() 57 | notifications.show({ 58 | color: 'green', 59 | title: 'Success', 60 | message: `Successfully deleted the list!`, 61 | }); 62 | 63 | setLoading(false) 64 | } 65 | 66 | const testConnection = async () => { 67 | setLoading(true) 68 | const { message: result } = await triggerUpdate({ url: '/api/testConnection', method: 'POST', body: formValues }) 69 | console.log(result) 70 | if (result === 'success') { 71 | notifications.show({ 72 | color: 'green', 73 | title: 'Success', 74 | message: `Successfully triggered a test email! You should have received an email.`, 75 | }); 76 | } else { 77 | notifications.show({ 78 | color: 'red', 79 | title: 'Error', 80 | message: `Something went wrong! Check the server logs for more details.`, 81 | }); 82 | } 83 | setLoading(false) 84 | } 85 | 86 | const handleChange = (target: EventTarget): void => { 87 | const inputElement = target as HTMLInputElement; 88 | setFormValues({ 89 | ...formValues, 90 | [inputElement.name]: inputElement.value, 91 | }) 92 | } 93 | 94 | return
95 | handleChange(event.currentTarget)} 101 | required 102 | defaultValue={props.defaultValues?.name} 103 | /> 104 | 105 | handleChange(event.currentTarget)} 113 | required 114 | defaultValue={props.defaultValues?.email} 115 | /> 116 | 117 | Sending Type 118 | 119 | How do you want to send your emails?  120 | 121 | 122 | 123 | What should I choose? 124 | 125 | 126 | 127 | Sending via your email accont is only recommended for small lists (up to ~100-200 subscribers). 128 | Usually email providers limit or block bulk sending. Check your provider for more info.
129 | It is recommended to set up AWS SES instead. 130 | This ensures that your emails are delivered to your subscribers. 131 |
132 |
133 |
134 |
135 | setFormValues({...formValues, sending_type: val })} 142 | mb="md" 143 | /> 144 | 145 | { formValues.sending_type === 'ses' && <> 146 | handleChange(event.currentTarget)} 152 | required 153 | defaultValue={props.defaultValues?.ses_key} 154 | /> 155 | handleChange(event.currentTarget)} 162 | required 163 | defaultValue={props.defaultValues?.ses_secret} 164 | /> 165 | handleChange(event.currentTarget)} 171 | required 172 | defaultValue={props.defaultValues?.ses_region} 173 | /> 174 | } 175 | 176 | { formValues.sending_type === 'email' && <> 177 | handleChange(event.currentTarget)} 184 | required 185 | defaultValue={props.defaultValues?.email_pass} 186 | /> 187 | handleChange(event.currentTarget)} 193 | required 194 | defaultValue={props.defaultValues?.email_host} 195 | /> 196 | } 197 | 198 | 199 | 200 | 201 | { props.isUpdate && } 202 | 203 | 204 | 205 | 206 | 207 | Deleting the mailing list will remove all subscribers and campaings. This can not be reversed. 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | } 216 | 217 | export default NewsetterSettings 218 | -------------------------------------------------------------------------------- /src/components/NumberCard.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Card, Text } from '@mantine/core'; 3 | 4 | type Props = { 5 | title: string, 6 | count: number, 7 | symbol?: string, 8 | } 9 | 10 | const NumberCard = (props: Props) => { 11 | return 12 | {props.title} 13 | {props.count}{props.symbol} 14 | 15 | } 16 | 17 | export default NumberCard 18 | -------------------------------------------------------------------------------- /src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import '@/styles/globals.css' 2 | import '@mantine/core/styles.css'; 3 | import '@mantine/dates/styles.css'; 4 | import '@mantine/notifications/styles.css'; 5 | import '@mantine/code-highlight/styles.css'; 6 | import '@mantine/dropzone/styles.css'; 7 | 8 | import type { AppProps } from 'next/app' 9 | import { MantineProvider, createTheme } from '@mantine/core'; 10 | import { Notifications } from '@mantine/notifications'; 11 | import { SessionProvider } from "next-auth/react" 12 | 13 | const theme = createTheme({ 14 | /** Put your mantine theme override here */ 15 | }); 16 | 17 | export default function App({ Component, pageProps: { session, ...pageProps } }: AppProps) { 18 | return 19 | 20 | 21 | 22 | 23 | 24 | } 25 | -------------------------------------------------------------------------------- /src/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import { Html, Head, Main, NextScript } from 'next/document' 2 | 3 | export default function Document() { 4 | return ( 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /src/pages/api/accounts.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiResponse } from 'next' 2 | import { createHash } from 'crypto' 3 | import withAuth from '../../../lib/auth'; 4 | import withMongoDB, { CustomRequest } from '../../../lib/db' 5 | import { AdminDAO } from '../../../lib/models/admin' 6 | 7 | type Result = { 8 | message: string, 9 | } | { 10 | exists: boolean, 11 | } | { 12 | count: number, 13 | } 14 | 15 | const createAccount = async (req: CustomRequest, res: NextApiResponse) => { 16 | const adminDAO = new AdminDAO(req.db); 17 | const hash = createHash('sha256'); 18 | hash.update(req.body.password); 19 | const hashedPassword = hash.digest('hex'); 20 | 21 | await adminDAO.createAccount({ 22 | username: req.body.username, 23 | password: hashedPassword, 24 | role: 'admin', 25 | }) 26 | 27 | res.status(200).json({ message: 'success' }) 28 | } 29 | 30 | async function handler( 31 | req: CustomRequest, 32 | res: NextApiResponse 33 | ) { 34 | if (req.method === 'POST') { 35 | const adminDAO = new AdminDAO(req.db); 36 | const accounts = await adminDAO.getAllByQuery({}) 37 | if (accounts.length > 0) { 38 | await withAuth(req, res, createAccount) 39 | } else { 40 | // first account can be created without auth 41 | await createAccount(req, res) 42 | } 43 | } else if (req.method === 'GET') { 44 | const adminDAO = new AdminDAO(req.db); 45 | const accounts = await adminDAO.getAllByQuery({}) 46 | const count = accounts.length 47 | 48 | res.status(200).json({ count }) 49 | } else { 50 | res.status(405).json({ message: 'Method not allowed' }) 51 | } 52 | } 53 | 54 | export default withMongoDB(handler, 'settings'); -------------------------------------------------------------------------------- /src/pages/api/admin.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiResponse } from 'next' 2 | import { getServerSession } from "next-auth/next" 3 | import withMongoDB, { CustomRequest } from '../../../lib/db' 4 | import { AdminDAO, Settings } from '../../../lib/models/admin' 5 | import withAuth from '../../../lib/auth'; 6 | import { authOptions } from "../api/auth/[...nextauth]" 7 | 8 | const getDBName = (inputString: string) => { 9 | let modifiedString = inputString.toLowerCase(); 10 | modifiedString = modifiedString.replace(/[^\w\s]/g, ''); // Remove special characters 11 | modifiedString = modifiedString.replace(/\s+/g, '-'); // Replace spaces with "-" 12 | return modifiedString; 13 | } 14 | 15 | interface SettingsWithUrl extends Settings { 16 | base_url: string, 17 | } 18 | 19 | type Result = { 20 | message: string, 21 | } | { 22 | initialized: boolean, 23 | } | SettingsWithUrl 24 | 25 | const updateSettings = async (req: CustomRequest, res: NextApiResponse) => { 26 | const adminDAO = new AdminDAO(req.db); 27 | const settings = await adminDAO.getSettings(); 28 | 29 | if (!settings) { 30 | await adminDAO.createSettings(req.body) 31 | } else { 32 | await adminDAO.updateSettings({ _id: settings._id }, req.body) 33 | } 34 | 35 | res.status(200).json({ message: 'success' }) 36 | } 37 | 38 | const addNewsletter = async (req: CustomRequest, res: NextApiResponse) => { 39 | const adminDAO = new AdminDAO(req.db); 40 | const settings = await adminDAO.getSettings(); 41 | 42 | let dbName = getDBName(req.body.name); 43 | 44 | if (settings) { // newsletters exist already 45 | let i = 1 46 | while (settings.newsletters.find((n) => n.database === dbName)) { 47 | i++; 48 | dbName = `${getDBName(req.body.name)}-${i}` 49 | } 50 | 51 | await adminDAO.updateSettings({ _id: settings._id }, { 52 | newsletters: [ 53 | ...settings.newsletters, 54 | { 55 | name: req.body.name, 56 | database: dbName, 57 | } 58 | ], 59 | }) 60 | } else { // first newsletter 61 | await adminDAO.createSettings({ 62 | newsletters: [{ 63 | name: req.body.name, 64 | database: dbName, 65 | }] 66 | }) 67 | } 68 | 69 | res.status(200).json({ message: dbName }) 70 | } 71 | 72 | async function handler( 73 | req: CustomRequest, 74 | res: NextApiResponse 75 | ) { 76 | if (req.method === 'GET') { 77 | const adminDAO = new AdminDAO(req.db); 78 | const settings = await adminDAO.getSettings(); 79 | const session = await getServerSession(req, res, authOptions) 80 | const initialized = (settings?.newsletters || []).length > 0 81 | 82 | if (session) { 83 | res.status(200).json({ 84 | ...(settings || {}), 85 | initialized, 86 | base_url: process.env.BASE_URL, 87 | }) 88 | } else { 89 | res.status(200).json({ initialized }) 90 | } 91 | } else if (req.method === 'POST') { 92 | await withAuth(req, res, updateSettings) 93 | } else if (req.method ==='PUT') { 94 | await withAuth(req, res, addNewsletter) 95 | } { 96 | res.status(405).json({ message: 'Method not allowed' }) 97 | } 98 | } 99 | 100 | export default withMongoDB(handler, 'settings'); -------------------------------------------------------------------------------- /src/pages/api/auth/[...nextauth].js: -------------------------------------------------------------------------------- 1 | import { MongoClient } from 'mongodb' 2 | import { createHash } from 'crypto' 3 | import NextAuth from "next-auth" 4 | import CredentialsProvider from "next-auth/providers/credentials" 5 | import { AdminDAO } from '../../../../lib/models/admin' 6 | 7 | const uri = process.env.MONGODB_URI || '' 8 | const options = {} 9 | 10 | export const authOptions = { 11 | secret: process.env.AUTH_SECRET, 12 | providers: [ 13 | CredentialsProvider({ 14 | name: 'Credentials', 15 | credentials: { 16 | username: { label: "Username", type: "text", placeholder: "jsmith" }, 17 | password: { label: "Password", type: "password" } 18 | }, 19 | async authorize(credentials, req) { 20 | let mongoClient 21 | try { 22 | const client = new MongoClient(uri, options); 23 | mongoClient= await client.connect(); 24 | const db = client.db('settings'); 25 | 26 | const adminDAO = new AdminDAO(db); 27 | const [account] = await adminDAO.getAllByQuery({ username: credentials.username }) 28 | const hash = createHash('sha256'); 29 | hash.update(credentials.password); 30 | const inputPasswordHash = hash.digest('hex'); 31 | 32 | if (account && account.password == inputPasswordHash) { 33 | return { username: credentials.username } 34 | } else { 35 | return null 36 | } 37 | } catch (error) { 38 | console.error('Error occurred:', error); 39 | return null 40 | } finally { 41 | if (mongoClient) { 42 | mongoClient.close() 43 | } 44 | } 45 | } 46 | }) 47 | ], 48 | } 49 | 50 | export default NextAuth(authOptions) -------------------------------------------------------------------------------- /src/pages/api/campaigns.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiResponse } from 'next' 2 | import { v4 as uuidv4 } from 'uuid'; 3 | import withMongoDB, { CustomRequest } from '../../../lib/db'; 4 | import withAuth from '../../../lib/auth'; 5 | import { CampaignDAO, Campaign } from '../../../lib/models/campaigns' 6 | import { SubscriberDAO } from '../../../lib/models/subscriber' 7 | 8 | type Result = { 9 | message: string, 10 | } 11 | 12 | async function getCampaigns(req: CustomRequest, res: NextApiResponse) { 13 | try { 14 | const campaignDao = new CampaignDAO(req.db); 15 | const campaigns = await campaignDao.getAll(); 16 | res.status(200).json(campaigns) 17 | } catch (e) { 18 | console.error(e) 19 | res.status(500).json({ message: 'Internal Server Error' }) 20 | } 21 | } 22 | 23 | async function sendCampaign(req: CustomRequest, res: NextApiResponse) { 24 | const campaignDao = new CampaignDAO(req.db); 25 | const subscriberDAO = new SubscriberDAO(req.db); 26 | const subscribers = await subscriberDAO.getAll({ $and: [ 27 | { "unsubscribedAt": { $exists: false }}, 28 | { "confirmed": true }, 29 | ] }); 30 | const newCampaignId = uuidv4(); 31 | 32 | await campaignDao.create({ 33 | id: newCampaignId, 34 | createdAt: new Date(), 35 | subject: req.body.subject, 36 | html: req.body.html, 37 | users: subscribers.map(s => ({ 38 | id: (s._id || '').toString(), 39 | status: 'pending', 40 | opens: 0, 41 | clicks: [], 42 | })) 43 | }) 44 | 45 | fetch(`${process.env.BASE_URL}/api/send`, { 46 | method: 'POST', 47 | headers: { 48 | 'Content-Type': 'application/json', 49 | 'x-mailing-list': req.headers['x-mailing-list']?.toString() || '' 50 | }, 51 | body: JSON.stringify({ 52 | api_key: process.env.API_KEY, 53 | campaignId: newCampaignId, 54 | }), 55 | }) 56 | 57 | res.status(200).json({ message:'success' }) 58 | } 59 | 60 | async function handler( 61 | req: CustomRequest, 62 | res: NextApiResponse 63 | ) { 64 | if (req.method === 'GET') { 65 | await withAuth(req, res, getCampaigns) 66 | } else if (req.method === 'POST') { 67 | await withAuth(req, res, sendCampaign) 68 | } else { 69 | res.status(405).json({ message: 'Method not allowed' }) 70 | } 71 | 72 | return Promise.resolve() 73 | } 74 | 75 | export default withMongoDB(handler); -------------------------------------------------------------------------------- /src/pages/api/confirm.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiResponse } from 'next' 2 | import withMongoDB, { CustomRequest, listExists } from '../../../lib/db' 3 | import { SubscriberDAO, Subscriber } from '../../../lib/models/subscriber' 4 | import { sendWelcomeEmail } from '../../../lib/email' 5 | 6 | type Error = { 7 | message: string, 8 | } 9 | 10 | const confirmSubscriber = async (req: CustomRequest, res: NextApiResponse) => { 11 | const subscriberDAO = new SubscriberDAO(req.db); 12 | const subscriber = await subscriberDAO.getByQuery({ confirmationId: req.query.id }) 13 | const list = req.query.list?.toString() || '' 14 | 15 | if (subscriber) { 16 | await subscriberDAO.updateByQuery( 17 | { confirmationId: req.query.id }, 18 | { confirmed: true } 19 | ); 20 | if (!subscriber.confirmed) { 21 | const userId = (subscriber._id || '').toString() 22 | await sendWelcomeEmail(subscriber.email, list, userId) 23 | } 24 | res.redirect(301, `${process.env.BASE_URL}/confirm-success`) 25 | } else { 26 | res.status(400).json({ 27 | message: 'Invalid confirmation link', 28 | }) 29 | } 30 | } 31 | 32 | async function handler( 33 | req: CustomRequest, 34 | res: NextApiResponse 35 | ) { 36 | if (req.method === 'GET') { 37 | try { 38 | if (!req.query.id || !req.query.list) { 39 | res.status(400).json({ 40 | message: 'Invalid confirmation link', 41 | }) 42 | } else if (req.query.id === 'test-confirm') { 43 | res.redirect(301, `${process.env.BASE_URL}/confirm-success`) 44 | } else { 45 | const validList = await listExists(req, res, req.query.list.toString()) 46 | if (validList) { 47 | await withMongoDB(confirmSubscriber, req.query.list.toString())(req, res) 48 | } else { 49 | res.status(400).json({ message: 'list does not exist' }) 50 | } 51 | } 52 | } catch (e) { 53 | console.error(e) 54 | res.status(500).json({ message: 'Internal Server Error' }) 55 | } 56 | } else { 57 | res.status(405).json({ message: 'Method not allowed' }) 58 | } 59 | 60 | return Promise.resolve() 61 | } 62 | 63 | export default handler; -------------------------------------------------------------------------------- /src/pages/api/dashboard.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiResponse } from 'next' 2 | import withMongoDB, { CustomRequest } from '../../../lib/db'; 3 | import withAuth from '../../../lib/auth'; 4 | import { CampaignDAO, Campaign } from '../../../lib/models/campaigns' 5 | import { SubscriberDAO } from '../../../lib/models/subscriber' 6 | 7 | type Error = { 8 | message: string, 9 | } 10 | 11 | type SubChartResult = { 12 | date: string, 13 | subscribes: number, 14 | } 15 | 16 | type Result = { 17 | subscribers: SubChartResult[], 18 | campaign: Campaign | null, 19 | subscriberCount: number, 20 | } 21 | 22 | const getDashboardData = async (req: CustomRequest, res: NextApiResponse) => { 23 | const subscriberDAO = new SubscriberDAO(req.db); 24 | const campaignDAO = new CampaignDAO(req.db); 25 | const sevenDaysAgo = new Date(); 26 | sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); 27 | sevenDaysAgo.setUTCHours(0,0,0,0); 28 | 29 | const subscribed = await subscriberDAO.getAll({ "createdAt": { $gte: sevenDaysAgo } }) 30 | const unsubscribed = await subscriberDAO.getAll({ "unsubscribedAt": { $gte: sevenDaysAgo } }) 31 | const subscriberCount = await subscriberDAO.getCount({ $and: [ 32 | { "unsubscribedAt": { $exists: false }}, 33 | { "confirmed": true }, 34 | ] }) 35 | 36 | const currentDate = new Date(); 37 | const subscribers = []; 38 | for (let i = 0; i < 7; i++) { 39 | const date = currentDate.toISOString().substring(0, 10) 40 | 41 | const dateSubs = subscribed 42 | .filter(s => s.createdAt.toISOString().substring(0, 10) === date) 43 | .length 44 | const dateUnsubs = unsubscribed 45 | .filter(s => !!s.unsubscribedAt) 46 | .filter(s => (s.unsubscribedAt || new Date()).toISOString().substring(0, 10) === date) 47 | .length 48 | 49 | currentDate.setDate(currentDate.getDate() - 1); 50 | 51 | subscribers.push({ date, subscribes: dateSubs - dateUnsubs }) 52 | } 53 | 54 | const campaign = await campaignDAO.getLatest() 55 | 56 | res.status(200).json({ 57 | subscribers: subscribers.reverse(), 58 | campaign, 59 | subscriberCount, 60 | }) 61 | } 62 | 63 | async function handler( 64 | req: CustomRequest, 65 | res: NextApiResponse 66 | ) { 67 | if (req.method === 'GET') { 68 | await withAuth(req, res, getDashboardData) 69 | } else { 70 | res.status(405).json({ message: 'Method not allowed' }) 71 | } 72 | } 73 | 74 | export default withMongoDB(handler); -------------------------------------------------------------------------------- /src/pages/api/import.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiResponse } from 'next' 2 | import { v4 as uuidv4 } from 'uuid'; 3 | import withMongoDB, { CustomRequest } from '../../../lib/db'; 4 | import { SubscriberDAO, Subscriber } from '../../../lib/models/subscriber' 5 | import withAuth from '../../../lib/auth'; 6 | 7 | type Result = { 8 | message: string, 9 | } | { 10 | progress: number, 11 | } 12 | 13 | let globalState = { progress: 0 } 14 | 15 | async function createSubscriber(req: CustomRequest, res: NextApiResponse) { 16 | try { 17 | const subscribers = req.body 18 | const subscriberDAO = new SubscriberDAO(req.db); 19 | globalState.progress = 0 20 | 21 | res.status(200).json({ message: 'success' }) 22 | 23 | for (let i = 0; i < subscribers.length; i++) { 24 | await new Promise(resolve => setTimeout(resolve, 500)) 25 | globalState.progress = (i+1) 26 | const { email, received = 0, opened = 0, clicked = 0, createdAt, location } = subscribers[i] 27 | const existingSubscriber = await subscriberDAO.getByQuery({ email }) 28 | 29 | if (!existingSubscriber) { 30 | const confirmationId = uuidv4(); 31 | await subscriberDAO.create({ 32 | email, 33 | name: null, 34 | createdAt: new Date(createdAt), 35 | confirmed: true, 36 | confirmationId, 37 | groups: [], 38 | location: location, 39 | received: parseInt(received, 10), 40 | opened: parseInt(opened, 10), 41 | clicked: parseInt(clicked, 10), 42 | }); 43 | } 44 | }; 45 | } 46 | catch (e) { 47 | console.error(e) 48 | res.status(500).json({ message: 'Internal Server Error' }) 49 | } 50 | } 51 | 52 | async function handler( 53 | req: CustomRequest, 54 | res: NextApiResponse 55 | ) { 56 | if (req.method === 'POST') { 57 | await withAuth(req, res, createSubscriber) 58 | } else if (req.method === 'GET') { 59 | res.status(200).json(globalState) 60 | } else { 61 | res.status(405).json({ message: 'Method not allowed' }) 62 | } 63 | 64 | return Promise.resolve() 65 | } 66 | 67 | export default withMongoDB(handler); -------------------------------------------------------------------------------- /src/pages/api/link/[[...slug]].tsx: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next' 2 | 3 | const handler = async (req: NextApiRequest, res: NextApiResponse) => { 4 | res.setHeader('Cache-Control', 'no-store') 5 | 6 | const { slug = [] } = req.query 7 | 8 | if (slug.length === 4) { 9 | const userId = atob(slug[0]); 10 | const campaignId = atob(slug[1]); 11 | const link = atob(slug[2]); 12 | const list = atob(slug[3]); 13 | 14 | fetch(`${process.env.BASE_URL}/api/track`, { 15 | cache: 'no-store', 16 | method: 'POST', 17 | headers: { 18 | 'Content-Type': 'application/json', 19 | }, 20 | body: JSON.stringify({ 21 | api_key: process.env.API_KEY, 22 | type: 'click', 23 | userId, 24 | campaignId, 25 | link, 26 | list, 27 | }), 28 | }) 29 | 30 | res.redirect(301, link) 31 | } else if (slug.length === 3) { // legacy emails 32 | const link = atob(slug[2]); 33 | res.redirect(301, link) 34 | } 35 | 36 | res.status(404).end() 37 | } 38 | 39 | export default handler -------------------------------------------------------------------------------- /src/pages/api/pixel/[[...slug]].tsx: -------------------------------------------------------------------------------- 1 | import { ImageResponse } from '@vercel/og' 2 | import type { NextApiRequest } from 'next' 3 | 4 | export const config = { 5 | runtime: 'edge', 6 | } 7 | 8 | const handler = async (req: NextApiRequest) => { 9 | const {searchParams} = new URL(req.url || ''); 10 | const params = searchParams.getAll('slug') 11 | 12 | if (params.length === 3) { 13 | const userId = params[0]; 14 | const campaignId = params[1]; 15 | const list = params[2]; 16 | 17 | fetch(`${process.env.BASE_URL}/api/track`, { 18 | method: 'POST', 19 | headers: { 20 | 'Content-Type': 'application/json', 21 | }, 22 | body: JSON.stringify({ 23 | api_key: process.env.API_KEY, 24 | type: 'open', 25 | userId, 26 | campaignId, 27 | list, 28 | }), 29 | }) 30 | } 31 | 32 | 33 | return new ImageResponse( 34 | (
), 35 | { 36 | width: 1, 37 | height: 1, 38 | } 39 | ) 40 | } 41 | 42 | export default handler -------------------------------------------------------------------------------- /src/pages/api/send.ts: -------------------------------------------------------------------------------- 1 | import { ObjectId } from 'mongodb'; 2 | import type { NextApiResponse } from 'next' 3 | import withMongoDB, { CustomRequest } from '../../../lib/db'; 4 | import { CampaignDAO, Campaign } from '../../../lib/models/campaigns' 5 | import { SubscriberDAO } from '../../../lib/models/subscriber' 6 | import { sendCampaign } from '../../../lib/email'; 7 | 8 | type Result = { 9 | message: string, 10 | } 11 | 12 | async function handleCampaignSend(req: CustomRequest, res: NextApiResponse) { 13 | const campaignDao = new CampaignDAO(req.db); 14 | const subscriberDAO = new SubscriberDAO(req.db); 15 | const mailingList = req.headers['x-mailing-list']?.toString() || '' 16 | 17 | const campaign = await campaignDao.getByQuery({ id: req.body.campaignId }) 18 | const allPendingUsers = campaign.users.filter(u => u.status !== 'success') 19 | const sendingUsers = allPendingUsers.splice(0, 10) 20 | 21 | const promises = sendingUsers.map(async u => { 22 | const query = { _id: new ObjectId(u.id) } 23 | const user = await subscriberDAO.getByQuery(query) 24 | const result = await sendCampaign(user.email, campaign.subject, campaign.html, { userId: u.id, templateId: req.body.campaignId, list: mailingList }) 25 | 26 | if (result === 'success') { 27 | await subscriberDAO.increaseTrack(query, 'received') 28 | } 29 | 30 | return campaignDao.updateStatus(req.body.campaignId, u.id, result) 31 | }) 32 | 33 | await Promise.all(promises) 34 | 35 | if (allPendingUsers.length > 0) { // call recursively if pending users left 36 | fetch(`${process.env.BASE_URL}/api/send`, { 37 | method: 'POST', 38 | headers: { 39 | 'Content-Type': 'application/json', 40 | 'x-mailing-list': mailingList, 41 | }, 42 | body: JSON.stringify(req.body), 43 | }) 44 | } 45 | 46 | res.status(200).json({ message:'success' }) 47 | } 48 | 49 | async function handler( 50 | req: CustomRequest, 51 | res: NextApiResponse 52 | ) { 53 | if (req.method === 'POST') { 54 | if (req.body.api_key === process.env.API_KEY) { 55 | await handleCampaignSend(req, res) 56 | } else { 57 | res.status(401).json({ message: 'Unauthorized' }) 58 | } 59 | } else { 60 | res.status(405).json({ message: 'Method not allowed' }) 61 | } 62 | 63 | return Promise.resolve() 64 | } 65 | 66 | export default withMongoDB(handler); -------------------------------------------------------------------------------- /src/pages/api/settings.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiResponse } from 'next' 2 | import { ObjectId } from 'mongodb' 3 | import withMongoDB, { CustomRequest } from '../../../lib/db' 4 | import { Settings, SettingsDAO } from '../../../lib/models/settings' 5 | import { TemplatesDAO } from '../../../lib/models/templates' 6 | import { AdminDAO } from '../../../lib/models/admin' 7 | import withAuth from '../../../lib/auth'; 8 | import { getSubject, getHtml } from '../../../lib/templates/confirmation' 9 | 10 | type Result = { 11 | message: string, 12 | } 13 | 14 | const addSettings = async (req: CustomRequest, res: NextApiResponse) => { 15 | const settingsDAO = new SettingsDAO(req.db); 16 | const templateDAO = new TemplatesDAO(req.db) 17 | await Promise.all([ 18 | settingsDAO.create(req.body), 19 | templateDAO.create({ 20 | name: 'confirmation', 21 | subject: getSubject(req.body.name), 22 | html: getHtml(req.body.name), 23 | }), 24 | ]) 25 | 26 | res.status(200).json({ message:'success' }) 27 | } 28 | 29 | const getSettings = async (req: CustomRequest, res: NextApiResponse) => { 30 | const settingsDAO = new SettingsDAO(req.db); 31 | const result = await settingsDAO.getAll({}); 32 | 33 | res.status(200).json(result[0]) 34 | } 35 | 36 | const updateSettings = async (req: CustomRequest, res: NextApiResponse) => { 37 | const settingsDAO = new SettingsDAO(req.db); 38 | const { _id, ...update } = req.body 39 | const result = await settingsDAO.updateByQuery({ _id: new ObjectId(_id) }, update); 40 | res.status(200).json(result) 41 | } 42 | 43 | const updateAdmin = async (req: CustomRequest, res: NextApiResponse) => { 44 | const adminDAO = new AdminDAO(req.db); 45 | const settings = await adminDAO.getSettings(); 46 | const updatedNewsletters = settings.newsletters.map((n) => n.database === req.body.database 47 | ? { ...n, name: req.body.name } 48 | : n) 49 | 50 | await adminDAO.updateSettings({ _id: settings._id }, { newsletters: updatedNewsletters }); 51 | } 52 | 53 | const deleteNewsletter = async (req: CustomRequest, res: NextApiResponse) => { 54 | const adminDAO = new AdminDAO(req.db); 55 | const settings = await adminDAO.getSettings(); 56 | const updatedNewsletters = settings.newsletters.filter((n) => n.database !== req.body.database); 57 | 58 | await adminDAO.updateSettings({ _id: settings._id }, { newsletters: updatedNewsletters }); 59 | } 60 | 61 | const deleteDatabases = async (req: CustomRequest, res: NextApiResponse) => { 62 | await req.db.dropDatabase() 63 | 64 | res.status(200).json({ message:'success' }) 65 | } 66 | 67 | async function handleSettings(req: CustomRequest, res: NextApiResponse) { 68 | if (req.method === 'GET') { 69 | await withMongoDB(getSettings)(req, res) 70 | } else if (req.method === 'POST') { 71 | const { database } = req.body; 72 | 73 | await withMongoDB(addSettings, database)(req, res) 74 | } else if (req.method === 'PUT') { 75 | await withMongoDB(updateAdmin, 'settings')(req, res); 76 | await withMongoDB(updateSettings)(req, res) 77 | } else if (req.method === 'DELETE') { 78 | await withMongoDB(deleteNewsletter, 'settings')(req, res); 79 | await withMongoDB(deleteDatabases)(req, res) 80 | } else { 81 | res.status(405).json({ message: 'Method not allowed' }) 82 | } 83 | } 84 | 85 | async function handler( 86 | req: CustomRequest, 87 | res: NextApiResponse 88 | ) { 89 | await withAuth(req, res, handleSettings) 90 | } 91 | 92 | export default handler; -------------------------------------------------------------------------------- /src/pages/api/subscribe.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiResponse } from 'next' 2 | import { v4 as uuidv4 } from 'uuid'; 3 | import NextCors from 'nextjs-cors'; 4 | import withMongoDB, { CustomRequest, listExists } from '../../../lib/db'; 5 | import { SubscriberDAO, Subscriber } from '../../../lib/models/subscriber' 6 | import { validate } from 'jsonschema'; 7 | import { sendConfirmationEmail } from '../../../lib/email'; 8 | 9 | type Error = { 10 | message: string, 11 | } 12 | 13 | type Response = { 14 | subscribers: Subscriber[], 15 | total: number, 16 | } 17 | 18 | async function createSubscriber(req: CustomRequest, res: NextApiResponse) { 19 | try { 20 | const subscriberDAO = new SubscriberDAO(req.db); 21 | const existingSubscriber = await subscriberDAO.getByQuery({ email: req.body.email }) 22 | 23 | if (!existingSubscriber) { 24 | const confirmationId = uuidv4(); 25 | await subscriberDAO.create({ 26 | email: req.body.email, 27 | name: req.body.name || null, 28 | createdAt: new Date(), 29 | confirmed: false, 30 | confirmationId, 31 | groups: req.body.groups || [], 32 | received: 0, 33 | opened: 0, 34 | clicked: 0, 35 | }); 36 | await sendConfirmationEmail(req.body.email, { confirmationId, list: req.body.list }) 37 | } 38 | 39 | res.status(200).send('Successfully subscribed!') 40 | } catch (e) { 41 | console.error(e) 42 | res.status(500).json({ message: 'Internal Server Error' }) 43 | } 44 | } 45 | 46 | async function handler( 47 | req: CustomRequest, 48 | res: NextApiResponse 49 | ) { 50 | if (req.method === 'POST') { 51 | await NextCors(req, res, { 52 | // Options 53 | methods: ['GET', 'POST', 'OPTIONS'], 54 | origin: process.env.CORS_ORIGIN, 55 | optionsSuccessStatus: 200, 56 | }); 57 | 58 | const schema = { 59 | "type": "object", 60 | "properties": { 61 | "list": {"type": "string"}, 62 | "email": {"type": "string", "format": "email",}, 63 | "name": {"type": "string"}, 64 | "groups": {"type": "array"}, 65 | }, 66 | "required": ["email", "list"] 67 | }; 68 | const inputValidation = validate(req.body, schema) 69 | if (inputValidation.errors.length) { 70 | res.status(400).json({ message: 'wrong input' }) 71 | } else { 72 | const validList = await listExists(req, res, req.body.list) 73 | if (validList) { 74 | await withMongoDB(createSubscriber, req.body.list)(req, res) 75 | } else { 76 | res.status(400).json({ message: 'list does not exist' }) 77 | } 78 | } 79 | } else if (req.method === 'OPTIONS') { 80 | await NextCors(req, res, { 81 | // Options 82 | methods: ['GET', 'POST', 'OPTIONS'], 83 | origin: process.env.CORS_ORIGIN, 84 | optionsSuccessStatus: 200, 85 | }); 86 | 87 | res.status(200).end() 88 | } else { 89 | res.status(405).json({ message: 'Method not allowed' }) 90 | } 91 | 92 | return Promise.resolve() 93 | } 94 | 95 | export default handler; -------------------------------------------------------------------------------- /src/pages/api/subscribers.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiResponse } from 'next' 2 | import withMongoDB, { CustomRequest } from '../../../lib/db'; 3 | import withAuth from '../../../lib/auth'; 4 | import { SubscriberDAO, Subscriber } from '../../../lib/models/subscriber' 5 | 6 | type Error = { 7 | message: string, 8 | } 9 | 10 | type Response = { 11 | subscribers: Subscriber[], 12 | total: number, 13 | } 14 | 15 | async function getSubscribers(req: CustomRequest, res: NextApiResponse) { 16 | try { 17 | const { page = '1' } = req.query; 18 | const p = Array.isArray(page) 19 | ? parseInt(page[0], 10) 20 | : parseInt(page, 10); 21 | 22 | const subscriberDAO = new SubscriberDAO(req.db); 23 | const [total, subscribers] = await Promise.all([ 24 | subscriberDAO.getCount({}), 25 | subscriberDAO.getAll({}, p), 26 | ]) 27 | res.status(200).json({ 28 | subscribers, 29 | total, 30 | }) 31 | } catch (e) { 32 | console.error(e) 33 | res.status(500).json({ message: 'Internal Server Error' }) 34 | } 35 | } 36 | 37 | async function deleteSubscriber(req: CustomRequest, res: NextApiResponse) { 38 | try { 39 | const { email } = req.body; 40 | const subscriberDAO = new SubscriberDAO(req.db); 41 | await subscriberDAO.deleteByQuery({ email }); 42 | 43 | res.status(200).json({ 44 | message: 'success' 45 | }) 46 | } catch (e) { 47 | console.error(e) 48 | res.status(500).json({ message: 'Internal Server Error' }) 49 | } 50 | } 51 | 52 | async function handler( 53 | req: CustomRequest, 54 | res: NextApiResponse 55 | ) { 56 | if (req.method === 'GET') { 57 | await withAuth(req, res, getSubscribers) 58 | } else if (req.method === 'DELETE') { 59 | await withAuth(req, res, deleteSubscriber) 60 | } else { 61 | res.status(405).json({ message: 'Method not allowed' }) 62 | } 63 | 64 | return Promise.resolve() 65 | } 66 | 67 | export default withMongoDB(handler); -------------------------------------------------------------------------------- /src/pages/api/templates.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiResponse } from 'next' 2 | import withMongoDB, { CustomRequest } from '../../../lib/db'; 3 | import withAuth from '../../../lib/auth'; 4 | import { Templates, TemplatesDAO } from '../../../lib/models/templates' 5 | 6 | type Result = { 7 | message: string, 8 | } 9 | 10 | async function getTemplates(req: CustomRequest, res: NextApiResponse) { 11 | try { 12 | const templatesDao = new TemplatesDAO(req.db); 13 | const templates = await templatesDao.getAll({}); 14 | res.status(200).json(templates) 15 | } catch (e) { 16 | console.error(e) 17 | res.status(500).json({ message: 'Internal Server Error' }) 18 | } 19 | } 20 | 21 | async function updateTemplate(req: CustomRequest, res: NextApiResponse) { 22 | try { 23 | const templatesDao = new TemplatesDAO(req.db); 24 | const templates = await templatesDao.getAll({}); 25 | const template = templates.find((t) => t.name === req.body.name); 26 | if (template) { 27 | await templatesDao.updateByQuery({ name: req.body.name }, { html: req.body.html, subject: req.body.subject }); 28 | } else { 29 | await templatesDao.create(req.body) 30 | } 31 | 32 | res.status(200).json({ message: 'Success' }) 33 | } catch (e) { 34 | console.error(e) 35 | res.status(500).json({ message: 'Internal Server Error' }) 36 | } 37 | } 38 | 39 | async function handler( 40 | req: CustomRequest, 41 | res: NextApiResponse 42 | ) { 43 | if (req.method === 'GET') { 44 | await withAuth(req, res, getTemplates) 45 | } else if (req.method === 'PUT') { 46 | await withAuth(req, res, updateTemplate) 47 | } else { 48 | res.status(405).json({ message: 'Method not allowed' }) 49 | } 50 | 51 | return Promise.resolve() 52 | } 53 | 54 | export default withMongoDB(handler); -------------------------------------------------------------------------------- /src/pages/api/testConnection.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiResponse, NextApiRequest } from 'next' 2 | import sendEmail from '../../../lib/email' 3 | 4 | type Result = { 5 | message: string, 6 | } 7 | 8 | async function handler( 9 | req: NextApiRequest, 10 | res: NextApiResponse 11 | ) { 12 | if (req.method === 'POST') { 13 | const result = await sendEmail(req.body.email, '[Testing email connection]', 'This is an OpenMailer test email', req.body) 14 | 15 | res.status(200).json({ message: result }) 16 | } else { 17 | res.status(405).json({ message: 'Method not allowed' }) 18 | } 19 | 20 | return Promise.resolve() 21 | } 22 | 23 | export default handler; -------------------------------------------------------------------------------- /src/pages/api/testEmail.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiResponse } from 'next' 2 | import withAuth from '../../../lib/auth'; 3 | import withMongoDB, { CustomRequest } from '../../../lib/db'; 4 | import { sendCampaign } from '../../../lib/email'; 5 | 6 | type Result = { 7 | message: string, 8 | } 9 | 10 | async function handleTestSend(req: CustomRequest, res: NextApiResponse) { 11 | const list = req.headers['x-mailing-list']?.toString() || '' 12 | 13 | const link = `${process.env.BASE_URL}/api/confirm?id=test-confirm&list=${list}` 14 | const regex = new RegExp('{{CONFIRMATION_LINK}}', 'g'); 15 | const html = (req.body.html || '').replace(regex, link) 16 | const result = await sendCampaign(req.body.testEmail, req.body.subject, html, { userId: 'test-user', templateId: 'test-campaign', list }) 17 | 18 | res.status(200).json({ message: result }) 19 | } 20 | 21 | async function handler( 22 | req: CustomRequest, 23 | res: NextApiResponse 24 | ) { 25 | if (req.method === 'POST') { 26 | await withAuth(req, res, handleTestSend) 27 | } else { 28 | res.status(405).json({ message: 'Method not allowed' }) 29 | } 30 | 31 | return Promise.resolve() 32 | } 33 | 34 | export default withMongoDB(handler); -------------------------------------------------------------------------------- /src/pages/api/track.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiResponse } from 'next' 2 | import withMongoDB, { CustomRequest } from '../../../lib/db'; 3 | import { CampaignDAO } from '../../../lib/models/campaigns' 4 | import { SubscriberDAO } from '../../../lib/models/subscriber' 5 | import { ObjectId } from 'mongodb'; 6 | 7 | type Result = { 8 | message: string, 9 | } 10 | 11 | const handleTrack = async (req: CustomRequest, res: NextApiResponse) => { 12 | const campaingDao = new CampaignDAO(req.db); 13 | const subscriberDAO = new SubscriberDAO(req.db); 14 | try { 15 | if (req.body.type === 'open') { 16 | const updatedCampaign = await campaingDao.trackOpen(req.body.campaignId, req.body.userId) 17 | const updatedUser = updatedCampaign?.users.find(u => u.id === req.body.userId) 18 | if (updatedUser?.opens === 1) { 19 | await subscriberDAO.increaseTrack({ _id: new ObjectId(req.body.userId) }, 'opened') 20 | } 21 | } else if (req.body.type === 'click') { 22 | const updatedCampaign = await campaingDao.trackClick(req.body.campaignId, req.body.userId, req.body.link) 23 | const updatedUser = updatedCampaign?.users.find(u => u.id === req.body.userId) 24 | if (updatedUser?.clicks.length === 1) { 25 | await subscriberDAO.increaseTrack({ _id: new ObjectId(req.body.userId) }, 'clicked') 26 | } 27 | if (updatedUser?.opens === 0) { // tacking pixel apparently didn't work - so track open here 28 | await subscriberDAO.increaseTrack({ _id: new ObjectId(req.body.userId) }, 'opened') 29 | } 30 | } 31 | res.status(200).json({ message: 'success' }) 32 | } catch(err) { 33 | console.error('Error on tracking:', err) 34 | res.status(500).json({ message: 'Internal Server Error' }) 35 | } 36 | } 37 | 38 | async function handler( 39 | req: CustomRequest, 40 | res: NextApiResponse 41 | ) { 42 | if (req.method === 'POST') { 43 | if (req.body.api_key === process.env.API_KEY) { 44 | const { list } = req.body 45 | await withMongoDB(handleTrack, list)(req, res) 46 | } else { 47 | res.status(401).json({ message: 'Unauthorized' }) 48 | } 49 | } else { 50 | res.status(405).json({ message: 'Method not allowed' }) 51 | } 52 | } 53 | 54 | export default handler; -------------------------------------------------------------------------------- /src/pages/api/unsubscribe.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiResponse } from 'next' 2 | import { ObjectId } from 'mongodb' 3 | import withMongoDB, { CustomRequest, listExists } from '../../../lib/db' 4 | import { SubscriberDAO } from '../../../lib/models/subscriber' 5 | 6 | type Result = { 7 | message: string, 8 | } 9 | 10 | const handleUnsubscribe = async (req: CustomRequest, res: NextApiResponse) => { 11 | const id = (req.query.id || '').toString() 12 | if (id !== 'test-user') { 13 | const subscriberDAO = new SubscriberDAO(req.db); 14 | await subscriberDAO.updateByQuery( 15 | { _id: new ObjectId(id) }, 16 | { unsubscribedAt: new Date() } 17 | ); 18 | } 19 | 20 | res.status(200).json({ message: 'Successfully unsubscribed'}) 21 | } 22 | 23 | async function handler( 24 | req: CustomRequest, 25 | res: NextApiResponse 26 | ) { 27 | if (req.method === 'GET') { 28 | const list = (req.query.list || '').toString() 29 | 30 | const validList = await listExists(req, res, list) 31 | if (validList) { 32 | await withMongoDB(handleUnsubscribe, list)(req, res) 33 | } else { 34 | res.status(400).json({ message: 'invalid link - please contact the newsletter owner' }) 35 | } 36 | } else { 37 | res.status(405).json({ message: 'Method not allowed' }) 38 | } 39 | } 40 | 41 | export default handler; -------------------------------------------------------------------------------- /src/pages/app/Layout.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, useEffect, useState } from 'react' 2 | import useSWR from 'swr' 3 | import Head from 'next/head' 4 | import Link from 'next/link' 5 | import { useRouter } from 'next/router' 6 | import { signOut, useSession } from "next-auth/react" 7 | import { useDisclosure, useLocalStorage } from '@mantine/hooks' 8 | import { AppShell, Burger, NavLink, Title, Flex, LoadingOverlay, Select, Modal, Text } from '@mantine/core' 9 | import { IconHome2, IconMail, IconUsersGroup, IconTemplate, IconSettings, IconLogout2 } from '@tabler/icons-react'; 10 | import NewsetterSettings from '@/components/NewsletterSettings' 11 | import fetcher from '../../utils/fetcher' 12 | import { Newsletter } from '../../../lib/models/admin' 13 | 14 | type LayoutProps = { 15 | title: String, 16 | isLoading: Boolean, 17 | children: ReactNode 18 | } 19 | 20 | export default function Layout(props: LayoutProps) { 21 | const [loading, setLoading] = useState(false) 22 | const [opened, { toggle }] = useDisclosure(); 23 | const [modalOpened, { open, close }] = useDisclosure(false); 24 | const { asPath: path } = useRouter(); 25 | const { data: session } = useSession(); 26 | const [mailingList, setMailingList] = useLocalStorage({ key: 'selected-mailing-list' }); 27 | 28 | const { data: { initialized, newsletters = [] } = {}, isLoading, mutate } = useSWR('/api/admin', fetcher) 29 | const router = useRouter() 30 | const activeList = newsletters.find((n: Newsletter) => n.database === mailingList) 31 | 32 | const onCreateSuccess = (database: string | undefined) => { 33 | if (database) { 34 | mutate() 35 | setMailingList(database) 36 | router.push('/app') 37 | close() 38 | } 39 | } 40 | 41 | useEffect(() => { 42 | if (session !== undefined && !isLoading) { 43 | if (!initialized || session === null) { 44 | const path = initialized 45 | ? '/api/auth/signin' 46 | : '/setup' 47 | 48 | router.push(path) 49 | } else { 50 | // mantine local storage hook returns undefined initially on re-render 51 | // prevent resetting the value by checking if it exists via native localstorage 52 | const item = localStorage.getItem('selected-mailing-list') 53 | if (!item) { 54 | setMailingList(newsletters[0].database) 55 | } 56 | } 57 | } 58 | }, [session, router, isLoading, initialized, newsletters, setMailingList, mailingList]) 59 | 60 | return ( 61 | <> 62 | 63 | OpenMailer 64 | 65 | 66 | 67 | 68 |
69 | 70 | 75 | 76 | 77 | 78 | 79 | OpenMailer | {props.title} 80 | 81 | 82 |