├── src ├── utils │ ├── updater.ts │ ├── fetcher.ts │ ├── campaign.ts │ └── apiMiddleware.ts ├── pages │ ├── _document.tsx │ ├── confirm-success.tsx │ ├── api │ │ ├── testConnection.ts │ │ ├── pixel │ │ │ └── [[...slug]].tsx │ │ ├── link │ │ │ └── [[...slug]].tsx │ │ ├── testEmail.ts │ │ ├── unsubscribe.ts │ │ ├── accounts.ts │ │ ├── auth │ │ │ └── [...nextauth].js │ │ ├── templates.ts │ │ ├── subscribers.ts │ │ ├── import.ts │ │ ├── confirm.ts │ │ ├── track.ts │ │ ├── send.ts │ │ ├── campaigns.ts │ │ ├── dashboard.ts │ │ ├── subscribe.ts │ │ ├── admin.ts │ │ └── settings.ts │ ├── _app.tsx │ ├── index.tsx │ ├── app │ │ ├── settings.tsx │ │ ├── index.tsx │ │ ├── campaigns │ │ │ ├── new.tsx │ │ │ └── index.tsx │ │ ├── subscribers.tsx │ │ ├── templates.tsx │ │ └── Layout.tsx │ └── setup.tsx ├── components │ ├── NumberCard.tsx │ ├── BrowserMockup.tsx │ ├── BrowserMockup.module.css │ ├── NewSubscribers.tsx │ ├── NewsletterSettings.tsx │ └── ImportForm.tsx └── styles │ ├── Templates.module.css │ └── globals.css ├── .github └── FUNDING.yml ├── .eslintrc.json ├── public └── favicon.ico ├── next.config.js ├── mailer.json ├── .env.dist ├── postcss.config.js ├── .gitignore ├── tsconfig.json ├── lib ├── auth.ts ├── templates │ ├── tracking-pixel.ts │ ├── unsubscribe.ts │ ├── welcome.ts │ └── confirmation.ts ├── models │ ├── templates.ts │ ├── settings.ts │ ├── admin.ts │ ├── subscriber.ts │ └── campaigns.ts ├── db.ts └── email.ts ├── LICENSE ├── package.json └── README.md /src/utils/updater.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | ko_fi: wweb_dev 2 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vincenius/OpenMailer/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | } 5 | 6 | module.exports = nextConfig 7 | -------------------------------------------------------------------------------- /src/utils/fetcher.ts: -------------------------------------------------------------------------------- 1 | const fetcher = async (...args: Parameters): Promise => { 2 | const response = await fetch(...args); 3 | const data = await response.json(); 4 | return data; 5 | }; 6 | 7 | export default fetcher -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | }; -------------------------------------------------------------------------------- /src/utils/campaign.ts: -------------------------------------------------------------------------------- 1 | import { Campaign } from '../../lib/models/campaigns'; 2 | 3 | export const getOpens = (campaign: Campaign | null) => 4 | campaign?.users.reduce((acc, curr) => acc + (curr.opens > 0 ? 1 : 0), 0) 5 | 6 | export const getUniqueClicks = (campaign: Campaign | null) => 7 | campaign?.users.reduce((acc, curr) => acc + (curr.clicks.length > 0 ? 1 : 0), 0) 8 | 9 | export const getRate = (count: number, received: number) => 10 | parseFloat(((count / received) * 100).toFixed(1)) -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 -------------------------------------------------------------------------------- /src/styles/Templates.module.css: -------------------------------------------------------------------------------- 1 | /* */ 2 | .cardHeaderContainer { 3 | position: relative; 4 | height: 200px; 5 | overflow: hidden; 6 | } 7 | 8 | .cardHeaderOverlay { 9 | position: absolute; 10 | top: 0; 11 | left: 0; 12 | width: 100%; 13 | height: 100%; 14 | background: linear-gradient(180deg, rgba(9,9,121,0) 70%, rgba(222, 227, 237, 1) 100%); 15 | z-index: 1; 16 | } 17 | 18 | .placeholder { 19 | text-align: center; 20 | width: 100%; 21 | opacity: 0.4; 22 | font-size: 1.6em; 23 | margin: 2em 0; 24 | text-transform: uppercase; 25 | display: inline-block; 26 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "paths": { 17 | "@/*": ["./src/*"] 18 | } 19 | }, 20 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 21 | "exclude": ["node_modules"] 22 | } 23 | -------------------------------------------------------------------------------- /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 -------------------------------------------------------------------------------- /src/pages/confirm-success.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head' 2 | import { Flex, Card, Title, Text } from '@mantine/core'; 3 | 4 | export default function ConfirmSuccess() { 5 | return ( 6 | 7 | Successfully subscribed! 8 | 9 | 10 | Successfully subscribed! 11 | You have successfully subscribed to our newsletter. Be on the lookout for our emails, which will be arriving shortly. 12 | Thank you for joining! 13 | 14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | import useSWR from 'swr' 3 | import { useSession } from "next-auth/react" 4 | import { useRouter } from 'next/navigation' 5 | import { LoadingOverlay } from '@mantine/core' 6 | import fetcher from '../utils/fetcher' 7 | 8 | export default function Home() { 9 | const { data: { initialized } = {}, error, isLoading } = useSWR('/api/admin', fetcher) 10 | const { data: session } = useSession() 11 | const router = useRouter() 12 | 13 | useEffect(() => { 14 | if (session !== undefined && !isLoading) { 15 | const path = initialized 16 | ? session !== null 17 | ? '/app' 18 | : '/api/auth/signin' 19 | : '/setup' 20 | 21 | router.push(path) 22 | } 23 | }, [session, router, isLoading, initialized]) 24 | 25 | return 26 | } 27 | -------------------------------------------------------------------------------- /src/pages/app/settings.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import { useFetch } from '@/utils/apiMiddleware' 3 | import NewsetterSettings from '@/components/NewsletterSettings'; 4 | import { notifications } from '@mantine/notifications'; 5 | import Layout from './Layout' 6 | 7 | export default function Subscribers() { 8 | const [loading, setLoading] = useState(false); 9 | const { data = {}, error, isLoading, mutate } = useFetch(`/api/settings`) 10 | 11 | const onSuccess = () => { 12 | notifications.show({ 13 | color: 'green', 14 | title: 'Success', 15 | message: `Successfully updated the newsletter settings!`, 16 | }); 17 | } 18 | 19 | return ( 20 | 21 | {!isLoading && } 29 | 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /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/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 -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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); -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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; -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/utils/apiMiddleware.ts: -------------------------------------------------------------------------------- 1 | import { useLocalStorage } from '@mantine/hooks'; 2 | import useSWR from 'swr' 3 | import { notifications } from '@mantine/notifications'; 4 | 5 | export const useFetch = (url: string) => { 6 | const [mailingList] = useLocalStorage({ key: 'selected-mailing-list' }); 7 | 8 | const fetcher = async ([url, mailingList]: [string, string?]): Promise => { 9 | const data = mailingList 10 | ? await fetch(url, { 11 | headers: { 'x-mailing-list': mailingList }, 12 | }).then(res => res.json()) 13 | : undefined 14 | 15 | return data; 16 | }; 17 | 18 | return useSWR([url, mailingList], fetcher) 19 | } 20 | 21 | type Props = { 22 | url: string; 23 | method: string; 24 | body: Object; 25 | } 26 | 27 | export const useUpdate = () => { 28 | const [mailingList] = useLocalStorage({ key: 'selected-mailing-list' }); 29 | 30 | const triggerUpdate = async ({ url, method, body }: Props) => { 31 | try { 32 | const response = await fetch(url, { 33 | method, 34 | headers: { 35 | 'Content-Type': 'application/json', 36 | 'x-mailing-list': mailingList, 37 | }, 38 | body: JSON.stringify(body), 39 | }); 40 | 41 | if (!response.ok) { 42 | throw new Error(`HTTP error! status: ${response.status}`); 43 | } 44 | 45 | return response.json(); // Assuming you expect a JSON response 46 | } catch (err) { 47 | notifications.show({ 48 | color: 'red', 49 | title: 'Error', 50 | message: 'Something went wrong...', 51 | }); 52 | } 53 | }; 54 | 55 | return { triggerUpdate }; 56 | }; 57 | -------------------------------------------------------------------------------- /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'); -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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); -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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; -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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/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/styles/globals.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --max-width: 1100px; 3 | --border-radius: 12px; 4 | --font-mono: ui-monospace, Menlo, Monaco, 'Cascadia Mono', 'Segoe UI Mono', 5 | 'Roboto Mono', 'Oxygen Mono', 'Ubuntu Monospace', 'Source Code Pro', 6 | 'Fira Mono', 'Droid Sans Mono', 'Courier New', monospace; 7 | 8 | --foreground-rgb: 0, 0, 0; 9 | --background-start-rgb: 214, 219, 220; 10 | --background-end-rgb: 255, 255, 255; 11 | 12 | --primary-glow: conic-gradient( 13 | from 180deg at 50% 50%, 14 | #16abff33 0deg, 15 | #0885ff33 55deg, 16 | #54d6ff33 120deg, 17 | #0071ff33 160deg, 18 | transparent 360deg 19 | ); 20 | --secondary-glow: radial-gradient( 21 | rgba(255, 255, 255, 1), 22 | rgba(255, 255, 255, 0) 23 | ); 24 | 25 | --tile-start-rgb: 239, 245, 249; 26 | --tile-end-rgb: 228, 232, 233; 27 | --tile-border: conic-gradient( 28 | #00000080, 29 | #00000040, 30 | #00000030, 31 | #00000020, 32 | #00000010, 33 | #00000010, 34 | #00000080 35 | ); 36 | 37 | --callout-rgb: 238, 240, 241; 38 | --callout-border-rgb: 172, 175, 176; 39 | --card-rgb: 180, 185, 188; 40 | --card-border-rgb: 131, 134, 135; 41 | } 42 | 43 | @media (prefers-color-scheme: dark) { 44 | :root { 45 | --foreground-rgb: 255, 255, 255; 46 | --background-start-rgb: 0, 0, 0; 47 | --background-end-rgb: 0, 0, 0; 48 | 49 | --primary-glow: radial-gradient(rgba(1, 65, 255, 0.4), rgba(1, 65, 255, 0)); 50 | --secondary-glow: linear-gradient( 51 | to bottom right, 52 | rgba(1, 65, 255, 0), 53 | rgba(1, 65, 255, 0), 54 | rgba(1, 65, 255, 0.3) 55 | ); 56 | 57 | --tile-start-rgb: 2, 13, 46; 58 | --tile-end-rgb: 2, 5, 19; 59 | --tile-border: conic-gradient( 60 | #ffffff80, 61 | #ffffff40, 62 | #ffffff30, 63 | #ffffff20, 64 | #ffffff10, 65 | #ffffff10, 66 | #ffffff80 67 | ); 68 | 69 | --callout-rgb: 20, 20, 20; 70 | --callout-border-rgb: 108, 108, 108; 71 | --card-rgb: 100, 100, 100; 72 | --card-border-rgb: 200, 200, 200; 73 | } 74 | } 75 | 76 | * { 77 | box-sizing: border-box; 78 | padding: 0; 79 | margin: 0; 80 | } 81 | 82 | html, 83 | body { 84 | max-width: 100vw; 85 | overflow-x: hidden; 86 | } 87 | 88 | body { 89 | color: rgb(var(--foreground-rgb)); 90 | background: linear-gradient( 91 | to bottom, 92 | transparent, 93 | rgb(var(--background-end-rgb)) 94 | ) 95 | rgb(var(--background-start-rgb)); 96 | } 97 | 98 | a { 99 | color: inherit; 100 | text-decoration: none; 101 | } 102 | 103 | @media (prefers-color-scheme: dark) { 104 | html { 105 | color-scheme: dark; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /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/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'); -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /src/pages/app/index.tsx: -------------------------------------------------------------------------------- 1 | import { Flex, Title, Text } from '@mantine/core' 2 | import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'; 3 | // import styles from '@/styles/Home.module.css' 4 | import NumberCard from '@/components/NumberCard'; 5 | import NewSubscribers from '@/components/NewSubscribers'; 6 | import ImportForm from '@/components/ImportForm'; 7 | import Layout from './Layout' 8 | import { getOpens, getUniqueClicks, getRate } from '../../utils/campaign'; 9 | import { useFetch } from '../../utils/apiMiddleware' 10 | 11 | type SubChartResult = { 12 | date: string, 13 | subscribes: number, 14 | subscriberCount: number, 15 | } 16 | 17 | export default function Home() { 18 | const { data = {}, error, isLoading, mutate } = useFetch('/api/dashboard') 19 | const subscribers: SubChartResult[] = data.subscribers || [] 20 | 21 | const gradientOffset = () => { 22 | const dataMax = Math.max(...subscribers.map((i) => i.subscribes)); 23 | const dataMin = Math.min(...subscribers.map((i) => i.subscribes)); 24 | 25 | if (dataMax <= 0) { 26 | return 0; 27 | } 28 | if (dataMin >= 0) { 29 | return 1; 30 | } 31 | 32 | return dataMax / (dataMax - dataMin); 33 | }; 34 | const off = gradientOffset(); 35 | 36 | const lastReceived = data.campaign?.users.length || 0 37 | const lastOpened = getRate(getOpens(data.campaign) || 0, lastReceived) 38 | const lastClicked = getRate(getUniqueClicks(data.campaign) || 0, lastReceived) 39 | 40 | if (isLoading) { 41 | return ( 42 | ) 43 | } 44 | 45 | if (data.subscriberCount === 0) { 46 | return ( 47 | Welcome to OpenMailer 48 | Get started by adding or importing your first subscribers. 49 | 50 | mutate()}/> 51 | 52 | ) 53 | } 54 | 55 | return ( 56 | 57 | Welcome back! 58 | 59 | 60 | 61 | 62 | 63 | Recent Subscriber Growth 64 | 65 | 66 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | ) 93 | } 94 | -------------------------------------------------------------------------------- /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/app/campaigns/new.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'next/navigation' 2 | import { useState } from 'react'; 3 | import { TextInput, Textarea, Flex, Box, Button, Modal, Text } from '@mantine/core'; 4 | import { useDisclosure } from '@mantine/hooks'; 5 | import { notifications } from '@mantine/notifications'; 6 | import Layout from "../Layout"; 7 | import { useUpdate, useFetch } from '@/utils/apiMiddleware' 8 | import BrowserMockup from '@/components/BrowserMockup' 9 | 10 | export default function NewCampaign() { 11 | const router = useRouter() 12 | const [subject, setSubject] = useState(''); 13 | const [html, setHtml] = useState('Preview'); 14 | const [loading, setLoading] = useState(false); 15 | const [testEmail, setTestEmail] = useState(''); 16 | const [opened, { open, close }] = useDisclosure(false); 17 | const [confirmOpened, { open: confirmOpen, close: confirmClose }] = useDisclosure(false); 18 | const { triggerUpdate } = useUpdate() 19 | const { data = {}, error, isLoading } = useFetch('/api/dashboard') 20 | 21 | const sendCampaign = ({ test = false }) => { 22 | setLoading(true) 23 | const url = test ? '/api/testEmail' : '/api/campaigns'; 24 | triggerUpdate({ url, method: 'POST', body: { 25 | subject, 26 | html, 27 | testEmail, 28 | }}).then(() => { 29 | if (test) { 30 | close() 31 | notifications.show({ 32 | color: 'green', 33 | title: 'Success', 34 | message: `Successfully sent test email to ${testEmail}!`, 35 | }); 36 | } else { 37 | confirmClose() 38 | router.push('/app/campaigns') 39 | notifications.show({ 40 | color: 'green', 41 | title: 'Success', 42 | message: `Successfully started campaign!`, 43 | }); 44 | } 45 | }).finally(() => { 46 | setLoading(false) 47 | }) 48 | } 49 | 50 | return ( 51 | 52 | 53 | 54 | setSubject(event.currentTarget.value)} 57 | label="Subject" 58 | mb="md" 59 | /> 60 |