├── .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 |
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('