├── .dockerignore ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── docker-publish.yml ├── .gitignore ├── Dockerfile ├── LICENCE ├── README.md ├── eslint.config.js ├── index.html ├── nginx.conf ├── package-lock.json ├── package.json ├── postcss.config.js ├── server ├── database │ ├── config.js │ └── migrate.js └── index.js ├── src ├── App.tsx ├── components │ ├── CampaignManager.tsx │ ├── ContactsUploader.tsx │ ├── ErrorDetails.tsx │ ├── Navbar.tsx │ ├── Navigation.tsx │ ├── TemplateEditor.tsx │ ├── contacts │ │ ├── AddContactForm.tsx │ │ ├── ContactListDetails.tsx │ │ ├── ContactListForm.tsx │ │ └── ContactListSelector.tsx │ ├── editor │ │ ├── RichTextEditor.tsx │ │ └── TemplateList.tsx │ └── tabs │ │ ├── CampaignTab.tsx │ │ ├── ContactsTab.tsx │ │ ├── SettingsTab.tsx │ │ └── TemplatesTab.tsx ├── i18n.ts ├── icon.png ├── index.css ├── locales │ ├── de.json │ ├── en.json │ ├── es.json │ └── fr.json ├── main.tsx ├── store │ └── useStore.ts ├── types │ └── index.ts ├── utils │ ├── csvParser.ts │ ├── emailService.ts │ ├── languageUtils.ts │ └── smtp.ts └── vite-env.d.ts ├── tailwind.config.js ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | .git 4 | .gitignore 5 | .env 6 | .dockerignore 7 | Dockerfile 8 | README.md 9 | .vscode 10 | .idea 11 | dist 12 | coverage 13 | .DS_Store -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. -------------------------------------------------------------------------------- /.github/workflows/docker-publish.yml: -------------------------------------------------------------------------------- 1 | name: Docker Publish & Auto Release 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | 7 | env: 8 | REGISTRY: ghcr.io 9 | IMAGE_NAME: ${{ github.repository }} 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | permissions: 15 | contents: read 16 | packages: write 17 | id-token: write 18 | 19 | steps: 20 | - name: Checkout repository 21 | uses: actions/checkout@v3.5.2 22 | 23 | - name: Install cosign 24 | uses: sigstore/cosign-installer@v3.7.0 25 | 26 | - name: Check cosign install 27 | run: cosign version 28 | 29 | - name: Setup Docker buildx 30 | uses: docker/setup-buildx-action@v2.5.0 31 | 32 | - name: Log into registry ${{ env.REGISTRY }} 33 | uses: docker/login-action@v2.1.0 34 | with: 35 | registry: ${{ env.REGISTRY }} 36 | username: ${{ github.actor }} 37 | password: ${{ secrets.GITHUB_TOKEN }} 38 | 39 | - name: Extract Docker metadata 40 | id: docker_meta 41 | uses: docker/metadata-action@v4.4.0 42 | with: 43 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 44 | tags: | 45 | type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }} 46 | 47 | - name: Build and push Docker image 48 | id: build-and-push 49 | uses: docker/build-push-action@v4.0.0 50 | with: 51 | context: . 52 | push: true 53 | tags: ${{ steps.docker_meta.outputs.tags }} 54 | labels: ${{ steps.docker_meta.outputs.labels }} 55 | 56 | - name: Sign images 57 | env: 58 | DIGEST: ${{ steps.build-and-push.outputs.digest }} 59 | TAGS: ${{ steps.docker_meta.outputs.tags }} 60 | run: | 61 | images="" 62 | for tag in ${TAGS}; do 63 | images+="${tag}@${DIGEST} " 64 | done 65 | cosign sign --yes ${images} 66 | 67 | release: 68 | runs-on: ubuntu-latest 69 | needs: [build] 70 | permissions: 71 | contents: write 72 | 73 | steps: 74 | - name: Checkout code 75 | uses: actions/checkout@v4 76 | 77 | - name: Create Release 78 | uses: dexwritescode/release-on-merge-action@v1 79 | with: 80 | generate-release-notes: true 81 | version-increment-strategy: patch 82 | tag-prefix: 'v' 83 | env: 84 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules 3 | npm-debug.log 4 | yarn-debug.log 5 | yarn-error.log 6 | old 7 | 8 | # Build output 9 | dist 10 | build 11 | 12 | # Environment variables 13 | .env 14 | .env.local 15 | .env.*.local 16 | 17 | # Editor directories 18 | .idea 19 | .vscode 20 | *.swp 21 | *.swo 22 | 23 | # OS files 24 | .DS_Store 25 | Thumbs.db 26 | 27 | # Application data 28 | data/ 29 | *.log -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build stage 2 | FROM node:18-alpine as builder 3 | 4 | WORKDIR /app 5 | 6 | # Copy package files 7 | COPY package*.json ./ 8 | 9 | # Install dependencies 10 | RUN npm install 11 | 12 | # Copy source code 13 | COPY . . 14 | 15 | # Build the frontend application 16 | RUN npm run build 17 | 18 | # Production stage 19 | FROM node:18-alpine 20 | 21 | WORKDIR /app 22 | 23 | # Instalar Nginx 24 | RUN apk add --no-cache nginx 25 | 26 | # Copy package files and install production dependencies 27 | COPY package*.json ./ 28 | RUN npm install --production 29 | 30 | # Copy built frontend assets 31 | COPY --from=builder /app/dist ./dist 32 | 33 | # Copy server files 34 | COPY server ./server 35 | 36 | # Copy nginx configuration 37 | COPY nginx.conf ./nginx.conf 38 | 39 | # Expose ports for both frontend and backend 40 | EXPOSE 80 3000 41 | 42 | # Create start script 43 | RUN echo -e '#!/bin/sh\nnginx -c /app/nginx.conf -g "daemon off;" & node server/index.js' > start.sh && \ 44 | chmod +x start.sh 45 | 46 | # Start both nginx and node server 47 | CMD ["./start.sh"] -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Giovanny Aranda 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Geoposler 3 | 4 | Is a Dockerized email campaign management application that allows users to create templates, manage contact lists, and send HTML-based emails via SMTP. It includes features to simplify email marketing campaigns and integrates seamlessly with a MySQL database for managing data. 5 | 6 |

7 | Image 2 8 | 9 |

10 | 11 |

12 | 13 | 14 | 15 | 16 |

17 | 18 | 19 | 20 | ## Features 21 | 22 | - Create and manage email templates. 23 | - Manage contact lists efficiently. 24 | - Send HTML emails using SMTP parameters. 25 | - Seamless MySQL database integration. 26 | 27 | --- 28 | 29 | ## Requirements 30 | 31 | 1. Docker installed on your system. 32 | 2. MySQL database connection details: 33 | - `DB_HOST` 34 | - `DB_USER` 35 | - `DB_PASSWORD` 36 | - `DB_NAME` 37 | 38 | --- 39 | 40 | ## Environment Variables 41 | 42 | | Variable | Description | Required | 43 | |----------------|--------------------------------------------|----------| 44 | | `DB_HOST` | MySQL database hostname or IP address | Yes | 45 | | `DB_USER` | MySQL database username | Yes | 46 | | `DB_PASSWORD` | MySQL database password | Yes | 47 | | `DB_NAME` | MySQL database name | Yes | 48 | 49 | --- 50 | 51 | ## Running the Application 52 | 53 | 1. Clone the repository: 54 | ```bash 55 | git clone https://github.com/garanda21/geoposler.git 56 | ``` 57 | 2. Navigate to the project directory: 58 | ```bash 59 | cd geoposler 60 | ``` 61 | 3. Set up the `.env` file with the required variables: 62 | ```env 63 | DB_HOST=localhost 64 | DB_USER=root 65 | DB_PASSWORD=mypassword 66 | DB_NAME=mycooldb 67 | ``` 68 | 4. Start the application with Docker Compose: 69 | ```bash 70 | docker-compose up --build 71 | ``` 72 | 73 | --- 74 | 75 | ## Docker Compose Example 76 | 77 | Below is an example `docker-compose.yml` to set up Geoposler along with a MySQL database: 78 | 79 | ```yaml 80 | services: 81 | geoposler: 82 | image: ghcr.io/garanda21/geoposler:latest 83 | ports: 84 | - "3454:80" 85 | environment: 86 | - DB_HOST=mysql 87 | - DB_USER=root 88 | - DB_PASSWORD=mypassword 89 | - DB_NAME=mycooldb 90 | depends_on: 91 | mysql: 92 | condition: service_healthy 93 | restart: unless-stopped 94 | 95 | mysql: 96 | image: mysql:8.0 97 | container_name: mysql 98 | ports: 99 | - "3306:3306" 100 | environment: 101 | MYSQL_ROOT_PASSWORD: mypassword 102 | MYSQL_DATABASE: mycooldb 103 | volumes: 104 | - mysql_data:/var/lib/mysql 105 | healthcheck: 106 | test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] 107 | interval: 10s 108 | timeout: 5s 109 | retries: 3 110 | 111 | volumes: 112 | mysql_data: 113 | ``` 114 | --- 115 | **IMPORTANT NOTICE**: The `mySQL` database service must be up and in a healthy state before starting the Geoposler service to ensure a successful connection to the database, using docker-compose you should add `depends_on` and `healthcheck` properties. 116 | 117 | ## Tech Stack 118 | 119 | - **Frontend:** Vite, React, Tailwind CSS 120 | - **Backend:** Node.js 121 | - **Database:** MySQL 122 | - **Containerization:** Docker 123 | 124 | --- 125 | 126 | ## License 127 | 128 | This project is licensed under the [MIT License](LICENCE). 129 | 130 | --- 131 | 132 | ## Contributing 133 | 134 | Contributions are welcome! Please open an issue or submit a pull request for any improvements or fixes. 135 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js'; 2 | import globals from 'globals'; 3 | import reactHooks from 'eslint-plugin-react-hooks'; 4 | import reactRefresh from 'eslint-plugin-react-refresh'; 5 | import tseslint from 'typescript-eslint'; 6 | 7 | export default tseslint.config( 8 | { ignores: ['dist'] }, 9 | { 10 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 11 | files: ['**/*.{ts,tsx}'], 12 | languageOptions: { 13 | ecmaVersion: 2020, 14 | globals: globals.browser, 15 | }, 16 | plugins: { 17 | 'react-hooks': reactHooks, 18 | 'react-refresh': reactRefresh, 19 | }, 20 | rules: { 21 | ...reactHooks.configs.recommended.rules, 22 | 'react-refresh/only-export-components': [ 23 | 'warn', 24 | { allowConstantExport: true }, 25 | ], 26 | }, 27 | } 28 | ); 29 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Geoposler 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /nginx.conf: -------------------------------------------------------------------------------- 1 | worker_processes auto; 2 | pid /tmp/nginx.pid; 3 | 4 | events { 5 | worker_connections 1024; 6 | } 7 | 8 | http { 9 | include /etc/nginx/mime.types; 10 | default_type application/octet-stream; 11 | 12 | access_log /tmp/access.log; 13 | error_log /tmp/error.log; 14 | 15 | sendfile on; 16 | keepalive_timeout 65; 17 | types_hash_max_size 2048; 18 | 19 | server { 20 | listen 80; 21 | server_name localhost; 22 | 23 | root /app/dist; 24 | index index.html; 25 | 26 | # Enable gzip compression 27 | gzip on; 28 | gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; 29 | 30 | # API proxy 31 | location /api/ { 32 | proxy_pass http://localhost:3000; 33 | proxy_http_version 1.1; 34 | proxy_set_header Upgrade $http_upgrade; 35 | proxy_set_header Connection 'upgrade'; 36 | proxy_set_header Host $host; 37 | proxy_cache_bypass $http_upgrade; 38 | } 39 | 40 | # Frontend routes 41 | location / { 42 | try_files $uri $uri/ /index.html; 43 | } 44 | 45 | # Cache static assets 46 | location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ { 47 | expires 30d; 48 | add_header Cache-Control "public, no-transform"; 49 | } 50 | } 51 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "email-campaign-manager", 3 | "private": true, 4 | "version": "1.0.1", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview", 11 | "server": "node server/index.js", 12 | "dev:all": "concurrently \"npm run dev\" \"npm run server\"" 13 | }, 14 | "dependencies": { 15 | "@monaco-editor/react": "^4.6.0", 16 | "@tiptap/extension-link": "^2.2.4", 17 | "@tiptap/pm": "^2.2.4", 18 | "@tiptap/react": "^2.2.4", 19 | "@tiptap/starter-kit": "^2.2.4", 20 | "axios": "^1.7.9", 21 | "cors": "^2.8.5", 22 | "express": "^4.21.2", 23 | "lucide-react": "^0.344.0", 24 | "mysql2": "^3.12.0", 25 | "nodemailer": "^6.9.12", 26 | "react": "^18.3.1", 27 | "react-dom": "^18.3.1", 28 | "react-hot-toast": "^2.4.1", 29 | "zustand": "^4.5.2", 30 | "node-persist": "^4.0.3", 31 | "date-fns": "^4.1.0", 32 | "react-router-dom": "^6.22.3", 33 | "i18next": "^24.2.2", 34 | "react-i18next": "^15.4.0", 35 | "i18next-http-backend": "^3.0.2", 36 | "i18next-browser-languagedetector": "^8.0.2", 37 | "concurrently": "^8.2.2" 38 | }, 39 | "devDependencies": { 40 | "@types/react": "^18.2.56", 41 | "@types/react-dom": "^18.2.19", 42 | "@typescript-eslint/eslint-plugin": "^7.0.2", 43 | "@typescript-eslint/parser": "^7.0.2", 44 | "@vitejs/plugin-react": "^4.2.1", 45 | "autoprefixer": "^10.4.18", 46 | "postcss": "^8.4.35", 47 | "tailwindcss": "^3.4.1", 48 | "typescript": "^5.2.2", 49 | "vite": "^5.1.4" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /server/database/config.js: -------------------------------------------------------------------------------- 1 | import { createPool } from 'mysql2/promise'; 2 | 3 | // Validate DB environment variables 4 | const requiredDbVars = ['DB_HOST', 'DB_USER', 'DB_PASSWORD', 'DB_NAME']; 5 | const missingVars = requiredDbVars.filter(varName => !process.env[varName]); 6 | 7 | if (missingVars.length > 0) { 8 | throw new Error(`Missing required database environment variables: ${missingVars.join(', ')}`); 9 | } 10 | 11 | export const dbConfig = { 12 | host: process.env.DB_HOST, 13 | user: process.env.DB_USER, 14 | password: process.env.DB_PASSWORD, 15 | database: process.env.DB_NAME, 16 | waitForConnections: true, 17 | connectionLimit: 10, 18 | queueLimit: 0, 19 | }; 20 | 21 | // Create a pool without database selection for initial setup 22 | export const initPool = createPool({ 23 | host: process.env.DB_HOST, 24 | user: process.env.DB_USER, 25 | password: process.env.DB_PASSWORD, 26 | waitForConnections: true, 27 | connectionLimit: 1, 28 | queueLimit: 0 29 | }); 30 | 31 | export const pool = createPool(dbConfig); -------------------------------------------------------------------------------- /server/database/migrate.js: -------------------------------------------------------------------------------- 1 | import { pool, initPool } from './config.js'; 2 | 3 | const migrations = [ 4 | // Create tables in order of dependencies 5 | `CREATE TABLE IF NOT EXISTS smtp_config ( 6 | id int NOT NULL AUTO_INCREMENT, 7 | host varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, 8 | port int NOT NULL, 9 | username varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, 10 | password varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, 11 | fromEmail varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, 12 | fromName varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, 13 | UNIQUE KEY idx (id) USING BTREE 14 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, 15 | 16 | `CREATE TABLE IF NOT EXISTS templates ( 17 | id varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, 18 | name varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, 19 | content text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, 20 | PRIMARY KEY (id) 21 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, 22 | 23 | `CREATE TABLE IF NOT EXISTS contact_lists ( 24 | id varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, 25 | name varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, 26 | PRIMARY KEY (id) 27 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, 28 | 29 | `CREATE TABLE IF NOT EXISTS contacts ( 30 | id varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, 31 | name varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, 32 | email varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, 33 | contact_list_id varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, 34 | PRIMARY KEY (id), 35 | KEY contact_list_id (contact_list_id), 36 | CONSTRAINT contacts_ibfk_1 FOREIGN KEY (contact_list_id) REFERENCES contact_lists (id) ON DELETE CASCADE 37 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, 38 | 39 | `CREATE TABLE IF NOT EXISTS campaigns ( 40 | id varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, 41 | name varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, 42 | subject varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, 43 | template_id varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, 44 | contact_list_id varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, 45 | status varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, 46 | sent_count int DEFAULT NULL, 47 | total_count int DEFAULT NULL, 48 | create_date datetime DEFAULT NULL, 49 | PRIMARY KEY (id), 50 | KEY template_id (template_id), 51 | KEY contact_list_id (contact_list_id), 52 | CONSTRAINT campaigns_ibfk_3 FOREIGN KEY (template_id) REFERENCES templates (id) ON DELETE CASCADE, 53 | CONSTRAINT campaigns_ibfk_4 FOREIGN KEY (contact_list_id) REFERENCES contact_lists (id) ON DELETE SET NULL ON UPDATE SET NULL 54 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, 55 | 56 | `CREATE TABLE IF NOT EXISTS errors ( 57 | id int NOT NULL AUTO_INCREMENT, 58 | email text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, 59 | error text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, 60 | campaign_id varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, 61 | PRIMARY KEY (id), 62 | KEY campaign_id (campaign_id) USING BTREE, 63 | CONSTRAINT errors_ibfk_1 FOREIGN KEY (campaign_id) REFERENCES campaigns (id) ON DELETE CASCADE 64 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, 65 | 66 | // Add initial SMTP config if needed 67 | `INSERT INTO smtp_config (id, host, port, username, password, fromEmail, fromName) 68 | SELECT 1, 'smtp.example.com', 587, 'default', 'default', 'no-reply@example.com', 'System' 69 | WHERE NOT EXISTS (SELECT 1 FROM smtp_config WHERE id = 1)`, 70 | 71 | //Modify table and add new column useSSL 72 | `ALTER TABLE smtp_config ADD COLUMN useSSL BOOLEAN DEFAULT 0 NOT NULL`, 73 | 74 | //Update smtp_config table and set useSSL to 0 if port is 587 or 1 if port is 465 75 | `UPDATE smtp_config SET useSSL = (SELECT CASE WHEN port = 465 THEN 1 ELSE 0 END) WHERE id = 1`, 76 | 77 | //Modify table and add new column useAuth 78 | `ALTER TABLE smtp_config ADD COLUMN useAuth BOOLEAN DEFAULT 0 NOT NULL`, 79 | 80 | //Enable by default the useAuth column 81 | `UPDATE smtp_config SET useAuth = 1 WHERE id = 1`, 82 | 83 | // Add new migration to support multiple contact lists per campaign 84 | `ALTER TABLE campaigns MODIFY COLUMN contact_list_id varchar(50) NULL`, 85 | 86 | `CREATE TABLE IF NOT EXISTS campaign_contact_lists ( 87 | id int NOT NULL AUTO_INCREMENT, 88 | campaign_id varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, 89 | contact_list_id varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, 90 | PRIMARY KEY (id), 91 | UNIQUE KEY campaign_contact_list_unique (campaign_id, contact_list_id), 92 | KEY campaign_id (campaign_id), 93 | KEY contact_list_id (contact_list_id), 94 | CONSTRAINT campaign_contact_lists_ibfk_1 FOREIGN KEY (campaign_id) REFERENCES campaigns (id) ON DELETE CASCADE, 95 | CONSTRAINT campaign_contact_lists_ibfk_2 FOREIGN KEY (contact_list_id) REFERENCES contact_lists (id) ON DELETE CASCADE 96 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, 97 | 98 | // Migration to move existing campaign contact list references to the junction table 99 | `INSERT INTO campaign_contact_lists (campaign_id, contact_list_id) 100 | SELECT id, contact_list_id FROM campaigns 101 | WHERE contact_list_id IS NOT NULL AND contact_list_id != ''` 102 | ]; 103 | 104 | async function runMigrations() { 105 | const connection = await pool.getConnection(); 106 | 107 | try { 108 | // Create migrations table if it doesn't exist 109 | await connection.query(` 110 | CREATE TABLE IF NOT EXISTS migrations ( 111 | id int NOT NULL AUTO_INCREMENT, 112 | migration_name varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, 113 | executed_at timestamp DEFAULT CURRENT_TIMESTAMP, 114 | PRIMARY KEY (id) 115 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci 116 | `); 117 | 118 | // Run each migration in a transaction 119 | for (const [index, migration] of migrations.entries()) { 120 | const tableName = migration.match(/CREATE TABLE IF NOT EXISTS (\w+)/)?.[1] || 'other'; 121 | const migrationName = `migration_${tableName}_${index + 1}`; 122 | 123 | // Check if migration was already executed 124 | const [executed] = await connection.query( 125 | 'SELECT 1 FROM migrations WHERE migration_name = ?', 126 | [migrationName] 127 | ); 128 | 129 | if (executed.length === 0) { 130 | await connection.beginTransaction(); 131 | 132 | try { 133 | // Run the migration 134 | await connection.query(migration); 135 | 136 | // Record the migration 137 | await connection.query( 138 | 'INSERT INTO migrations (migration_name) VALUES (?)', 139 | [migrationName] 140 | ); 141 | 142 | await connection.commit(); 143 | console.log(`Migration ${migrationName} executed successfully`); 144 | 145 | } catch (error) { 146 | await connection.rollback(); 147 | throw error; 148 | } 149 | } 150 | } 151 | 152 | console.log('All migrations completed successfully'); 153 | 154 | } catch (error) { 155 | console.error('Migration failed:', error); 156 | throw error; 157 | 158 | } finally { 159 | connection.release(); 160 | } 161 | } 162 | 163 | export async function ensureDatabase() { 164 | const connection = await initPool.getConnection(); 165 | try { 166 | await connection.query(`CREATE DATABASE IF NOT EXISTS ${process.env.DB_NAME} 167 | CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`); 168 | console.log(`Database ${process.env.DB_NAME} ensured`); 169 | } catch (error) { 170 | console.error('Failed to create database:', error); 171 | throw error; 172 | } finally { 173 | connection.release(); 174 | await initPool.end(); 175 | } 176 | } 177 | 178 | export async function initializeDatabase() { 179 | try { 180 | await ensureDatabase(); 181 | await runMigrations(); 182 | return true; 183 | } catch (error) { 184 | console.error('Database initialization failed:', error); 185 | return false; 186 | } 187 | } -------------------------------------------------------------------------------- /server/index.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import cors from 'cors'; 3 | import nodemailer from 'nodemailer'; 4 | import { format } from 'date-fns'; 5 | import { pool, dbConfig } from './database/config.js'; 6 | import { initializeDatabase } from './database/migrate.js'; 7 | 8 | const app = express(); 9 | app.use(cors()); 10 | app.use(express.json({ limit: '5mb' })); // Increased payload limit 11 | 12 | // Run migrations when server starts 13 | await initializeDatabase().catch(console.error); 14 | 15 | app.post('/api/verify-smtp', async (req, res) => { 16 | const config = req.body; 17 | 18 | try { 19 | const transportConfig = { 20 | host: config.host, 21 | port: config.port, 22 | secure: config.useSSL 23 | }; 24 | 25 | if (config.useAuth !== false) { 26 | transportConfig.auth = { 27 | user: config.username, 28 | pass: config.password, 29 | }; 30 | } 31 | 32 | const transporter = nodemailer.createTransport(transportConfig); 33 | 34 | 35 | await transporter.verify(); 36 | res.json({ success: true }); 37 | } catch (error) { 38 | res.status(400).json({ 39 | success: false, 40 | error: error.message 41 | }); 42 | } 43 | }); 44 | 45 | app.post('/api/send-email', async (req, res) => { 46 | const { contact, subject, content, smtpConfig } = req.body; 47 | 48 | try { 49 | const transportConfig = { 50 | host: smtpConfig.host, 51 | port: smtpConfig.port, 52 | secure: smtpConfig.useSSL 53 | }; 54 | 55 | if (smtpConfig.useAuth !== false) { 56 | transportConfig.auth = { 57 | user: smtpConfig.username, 58 | pass: smtpConfig.password, 59 | }; 60 | } 61 | const transporter = nodemailer.createTransport(transportConfig); 62 | 63 | await transporter.sendMail({ 64 | from: `"${smtpConfig.fromName}" <${smtpConfig.fromEmail}>`, 65 | to: `"${contact.name}" <${contact.email}>`, 66 | subject: subject, 67 | html: content, 68 | }); 69 | 70 | res.json({ success: true }); 71 | } catch (error) { 72 | res.status(400).json({ 73 | success: false, 74 | error: error.message 75 | }); 76 | } 77 | }); 78 | 79 | //Endpoints for connect to a mySQL database and get settings for later use on the app useStore.ts 80 | app.get('/api/settings', async (req, res) => { 81 | try 82 | { 83 | // Fetch templates 84 | const [templates] = await pool.query('SELECT id, name, content FROM templates'); 85 | 86 | // Fetch contact lists with their contacts 87 | const [contactLists] = await pool.query('SELECT id, name FROM contact_lists'); 88 | for (let list of contactLists) { 89 | const [contacts] = await pool.query( 90 | `SELECT id, name, email 91 | FROM contacts 92 | WHERE contact_list_id = '${list.id}'` 93 | ); 94 | list.contacts = contacts; 95 | } 96 | 97 | // Fetch campaigns with template and contact list names 98 | const [campaigns] = await pool.query(` 99 | SELECT 100 | c.id, 101 | c.name, 102 | c.subject, 103 | c.template_id as templateId, 104 | t.name as templateName, 105 | c.status, 106 | c.sent_count as sentCount, 107 | c.total_count as totalCount, 108 | c.create_date as createDate 109 | FROM campaigns c 110 | LEFT JOIN templates t ON c.template_id = t.id 111 | `); 112 | 113 | // Fetch contact lists for each campaign 114 | for (let campaign of campaigns) { 115 | const [campaignContactLists] = await pool.query(` 116 | SELECT 117 | cl.id as contactListId, 118 | cl.name as contactListName 119 | FROM campaign_contact_lists ccl 120 | JOIN contact_lists cl ON ccl.contact_list_id = cl.id 121 | WHERE ccl.campaign_id = ? 122 | `, [campaign.id]); 123 | 124 | campaign.contactListIds = campaignContactLists.map(cl => cl.contactListId); 125 | campaign.contactListNames = campaignContactLists.map(cl => cl.contactListName); 126 | 127 | // For backward compatibility 128 | if (campaignContactLists.length > 0) { 129 | campaign.contactListId = campaignContactLists[0].contactListId; 130 | campaign.contactListName = campaignContactLists[0].contactListName; 131 | } 132 | } 133 | 134 | // Fetch error details for each campaign 135 | for (let campaign of campaigns) { 136 | const [errors] = await pool.query(` 137 | SELECT email, error 138 | FROM errors 139 | WHERE campaign_id = '${campaign.id}' 140 | `); 141 | 142 | // Only add error field if there are errors 143 | if (errors.length > 0) { 144 | 145 | campaign.error = JSON.stringify(errors.map(err => ({ 146 | email: err.email, 147 | error: err.error 148 | }))); 149 | } else { 150 | campaign.error = null; 151 | } 152 | } 153 | 154 | // Fetch SMTP configuration 155 | const [smtpConfig] = await pool.query('SELECT * FROM smtp_config'); 156 | const smtp = smtpConfig[0] || { 157 | host: '', 158 | port: 587, 159 | username: '', 160 | password: '', 161 | fromEmail: '', 162 | fromName: '', 163 | useSSL: false, 164 | useAuth: true 165 | }; 166 | 167 | // Construct the response object 168 | const response = { 169 | templates: templates.map(t => ({ 170 | id: t.id, 171 | name: t.name, 172 | content: t.content 173 | })), 174 | contactLists: contactLists.map(cl => ({ 175 | id: cl.id, 176 | name: cl.name, 177 | contacts: cl.contacts.map(c => ({ 178 | id: c.id, 179 | name: c.name, 180 | email: c.email 181 | })) 182 | })), 183 | campaigns: campaigns.map(c => ({ 184 | id: c.id, 185 | name: c.name, 186 | subject: c.subject, 187 | templateId: c.templateId, 188 | templateName: c.templateName, 189 | contactListIds: c.contactListIds || [], 190 | contactListNames: c.contactListNames || [], 191 | status: c.status, 192 | sentCount: c.sentCount, 193 | totalCount: c.totalCount, 194 | createDate: format(new Date(c.createDate), 'dd/MM/yyyy HH:mm:ss'), 195 | error: c.error 196 | })), 197 | smtpConfig: { 198 | host: smtp.host, 199 | port: smtp.port, 200 | username: smtp.username, 201 | password: smtp.password, 202 | fromEmail: smtp.fromEmail, 203 | fromName: smtp.fromName, 204 | useSSL: smtp.useSSL, 205 | useAuth: smtp.useAuth 206 | } 207 | }; 208 | 209 | res.json(response); 210 | } 211 | catch (error) { 212 | console.error('Error fetching settings:', error); 213 | res.status(500).json({ error: 'Internal server error' }); 214 | } 215 | }); 216 | 217 | app.post('/api/settings', async (req, res) => { 218 | 219 | const { smtpConfig, action, data } = req.body; 220 | try { 221 | // Get a connection from the pool for the transaction 222 | const connection = await pool.getConnection(); 223 | 224 | try { 225 | 226 | // Start transaction 227 | await connection.beginTransaction(); 228 | 229 | // Handle SMTP config update if provided 230 | if (smtpConfig) { 231 | await connection.query( 232 | `UPDATE smtp_config 233 | SET host = ?, port = ?, username = ?, password = ?, 234 | fromEmail = ?, fromName = ?, useSSL = ?, useAuth = ? 235 | WHERE id = 1`, 236 | [smtpConfig.host, smtpConfig.port, smtpConfig.username, 237 | smtpConfig.password, smtpConfig.fromEmail, smtpConfig.fromName, smtpConfig.useSSL, smtpConfig.useAuth] 238 | ); 239 | } 240 | if (action && data) { 241 | console.log("ACTION:", action); 242 | console.log("DATA:", data); 243 | switch (action.type) { 244 | case 'ADD_TEMPLATE': 245 | await pool.query( 246 | 'INSERT INTO templates (id, name, content) VALUES (?, ?, ?)', 247 | [data.id, data.name, data.content] 248 | ); 249 | break; 250 | 251 | case 'UPDATE_TEMPLATE': 252 | // Build the query dynamically based on what fields are provided 253 | const updateFields = []; 254 | const updateValues = []; 255 | 256 | if (data.name !== undefined) { 257 | updateFields.push('name = ?'); 258 | updateValues.push(data.name); 259 | } 260 | if (data.content !== undefined) { 261 | updateFields.push('content = ?'); 262 | updateValues.push(data.content); 263 | } 264 | 265 | // Add the id for the WHERE clause 266 | updateValues.push(data.id); 267 | 268 | // Only proceed if there are fields to update 269 | if (updateFields.length > 0) { 270 | const updateQuery = ` 271 | UPDATE templates 272 | SET ${updateFields.join(', ')} 273 | WHERE id = ? 274 | `; 275 | await pool.query(updateQuery, updateValues); 276 | } 277 | break; 278 | 279 | case 'DELETE_TEMPLATE': 280 | await pool.query('DELETE FROM templates WHERE id = ?', [data.id]); 281 | break; 282 | 283 | case 'ADD_CONTACT_LIST': 284 | await pool.query( 285 | 'INSERT INTO contact_lists (id, name) VALUES (?, ?)', 286 | [data.id, data.name] 287 | ); 288 | if (data.contacts && data.contacts.length > 0) { 289 | const contactValues = data.contacts.map(c => 290 | [c.id, c.name, c.email, data.id] 291 | ); 292 | await pool.query( 293 | 'INSERT INTO contacts (id, name, email, contact_list_id) VALUES ?', 294 | [contactValues] 295 | ); 296 | } 297 | break; 298 | 299 | case 'UPDATE_CONTACT_LIST': 300 | if (data.contacts) { 301 | // Delete contacts that are no longer in the list 302 | const newEmailList = data.contacts.map(c => c.email); 303 | if (newEmailList.length > 0) { 304 | await pool.query( 305 | 'DELETE FROM contacts WHERE contact_list_id = ? AND email NOT IN (?)', 306 | [data.id, newEmailList] 307 | ); 308 | } 309 | 310 | // Insert only new contacts that don't exist 311 | if (data.contacts.length > 0) { 312 | for (const contact of data.contacts) { 313 | // Check if contact already exists 314 | const existing = await pool.query( 315 | 'SELECT id FROM contacts WHERE email = ? AND contact_list_id = ?', 316 | [contact.email, data.id] 317 | ); 318 | 319 | // Only insert if contact doesn't exist 320 | if (existing.length === 0) { 321 | await pool.query( 322 | 'INSERT INTO contacts (id, name, email, contact_list_id) VALUES (?, ?, ?, ?)', 323 | [contact.id, contact.name, contact.email, data.id] 324 | ); 325 | } 326 | } 327 | } 328 | } 329 | break; 330 | 331 | case 'DELETE_CONTACT_LIST': 332 | // Contacts will be deleted automatically due to foreign key constraint 333 | await pool.query('DELETE FROM contact_lists WHERE id = ?', [data.id]); 334 | break; 335 | 336 | case 'ADD_CAMPAIGN': 337 | // Convert the formatted date string back to MySQL datetime format 338 | const parsedDate = new Date(data.createDate); 339 | const mysqlDateTime = format(parsedDate, 'yyyy-MM-dd HH:mm:ss'); 340 | 341 | // Insert campaign without contact list reference 342 | await connection.query( 343 | `INSERT INTO campaigns 344 | (id, name, subject, template_id, status, sent_count, total_count, create_date) 345 | VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, 346 | [data.id, data.name, data.subject, data.templateId, 347 | data.status, data.sentCount, data.totalCount, mysqlDateTime] 348 | ); 349 | 350 | // Insert each contact list relationship 351 | if (Array.isArray(data.contactListIds) && data.contactListIds.length > 0) { 352 | const contactListValues = data.contactListIds.map(listId => [data.id, listId]); 353 | await connection.query( 354 | 'INSERT INTO campaign_contact_lists (campaign_id, contact_list_id) VALUES ?', 355 | [contactListValues] 356 | ); 357 | } 358 | // For backward compatibility 359 | else if (data.contactListId) { 360 | await connection.query( 361 | 'INSERT INTO campaign_contact_lists (campaign_id, contact_list_id) VALUES (?, ?)', 362 | [data.id, data.contactListId] 363 | ); 364 | } 365 | 366 | // Handle errors if present 367 | if (data.error) { 368 | const errors = JSON.parse(data.error); 369 | if (Array.isArray(errors) && errors.length > 0) { 370 | const errorValues = errors.map(err => 371 | [err.email, err.error, data.id] 372 | ); 373 | await connection.query( 374 | 'INSERT INTO errors (email, error, campaign_id) VALUES ?', 375 | [errorValues] 376 | ); 377 | } 378 | } 379 | break; 380 | 381 | case 'UPDATE_CAMPAIGN': 382 | // Build the query dynamically based on what fields are provided 383 | const updateCampaignFields = []; 384 | const updateCampaignValues = []; 385 | 386 | if (data.status !== undefined) { 387 | updateCampaignFields.push('status = ?'); 388 | updateCampaignValues.push(data.status); 389 | } 390 | if (data.sentCount !== undefined) { 391 | updateCampaignFields.push('sent_count = ?'); 392 | updateCampaignValues.push(data.sentCount); 393 | } 394 | 395 | // Add the id for the WHERE clause 396 | updateCampaignValues.push(data.id); 397 | 398 | // Only proceed if there are fields to update 399 | if (updateCampaignFields.length > 0) { 400 | const updateCampaignQuery = ` 401 | UPDATE campaigns 402 | SET ${updateCampaignFields.join(', ')} 403 | WHERE id = ? 404 | `; 405 | await connection.query(updateCampaignQuery, updateCampaignValues); 406 | } 407 | 408 | // Update contact lists if provided 409 | if (Array.isArray(data.contactListIds)) { 410 | // Delete all existing relations 411 | await connection.query( 412 | 'DELETE FROM campaign_contact_lists WHERE campaign_id = ?', 413 | [data.id] 414 | ); 415 | 416 | // Insert new relations 417 | if (data.contactListIds.length > 0) { 418 | const contactListValues = data.contactListIds.map(listId => [data.id, listId]); 419 | await connection.query( 420 | 'INSERT INTO campaign_contact_lists (campaign_id, contact_list_id) VALUES ?', 421 | [contactListValues] 422 | ); 423 | } 424 | } 425 | 426 | // Handle error updates 427 | if (data.error !== undefined) { 428 | await connection.query('DELETE FROM errors WHERE campaign_id = ?', [data.id]); 429 | if (data.error) { 430 | const errors = JSON.parse(data.error); 431 | if (Array.isArray(errors) && errors.length > 0) { 432 | const errorValues = errors.map(err => 433 | [err.email, err.error, data.id] 434 | ); 435 | await connection.query( 436 | 'INSERT INTO errors (email, error, campaign_id) VALUES ?', 437 | [errorValues] 438 | ); 439 | } 440 | } 441 | } 442 | break; 443 | 444 | case 'DELETE_CAMPAIGN': 445 | // The campaign and its relations will be deleted due to foreign key constraints 446 | await connection.query('DELETE FROM campaigns WHERE id = ?', [data.id]); 447 | break; 448 | 449 | default: 450 | throw new Error(`Unknown action type: ${action.type}`); 451 | } 452 | } 453 | // Commit the transaction 454 | await connection.commit(); 455 | res.json({ message: 'Settings saved successfully' }); 456 | } 457 | catch (error) { 458 | await connection.rollback(); 459 | console.error('Error saving settings:', error); 460 | res.status(500).json({ 461 | error: 'Failed to save settings', 462 | details: error.message 463 | }); 464 | } finally { 465 | connection.release(); 466 | } 467 | } catch (error) { 468 | // Handle any errors 469 | res.status(500).json({ error: error.message }); 470 | } 471 | }); 472 | const PORT = process.env.PORT || 3000; 473 | app.listen(PORT, () => { 474 | console.log(`Server running on port ${PORT}`); 475 | console.log('Database config:', { 476 | host: dbConfig.host, 477 | user: dbConfig.user, 478 | database: dbConfig.database 479 | }); 480 | }); -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; 3 | import { Navbar } from './components/Navbar'; 4 | import { Navigation } from './components/Navigation'; 5 | import { TemplatesTab } from './components/tabs/TemplatesTab'; 6 | import { ContactsTab } from './components/tabs/ContactsTab'; 7 | import { SettingsTab } from './components/tabs/SettingsTab'; 8 | import { CampaignTab } from './components/tabs/CampaignTab'; 9 | import { Toaster } from 'react-hot-toast'; 10 | import { useStore } from './store/useStore'; 11 | import { Github } from 'lucide-react'; 12 | 13 | function App() { 14 | 15 | const fetchSettings = useStore(state => state.fetchSettings); 16 | 17 | // Fetch settings when the app initializes 18 | useEffect(() => { 19 | fetchSettings(); 20 | }, [fetchSettings]); 21 | 22 | 23 | return ( 24 | 25 |
26 | 27 |
28 | 29 | 30 | } /> 31 | } /> 32 | } /> 33 | } /> 34 | } /> 35 | 36 |
37 | 53 | 54 |
55 |
56 | ); 57 | } 58 | 59 | export default App; -------------------------------------------------------------------------------- /src/components/CampaignManager.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef, useEffect } from 'react'; 2 | import { useStore } from '../store/useStore'; 3 | import { Play, Pause, CheckCircle, Trash2, Info, RotateCw, X, ChevronDown, Check } from 'lucide-react'; 4 | import toast from 'react-hot-toast'; 5 | import { sendEmail } from '../utils/emailService'; 6 | import { ErrorDetails } from './ErrorDetails'; 7 | import { Campaign, ContactList } from '../types'; 8 | import { format, parse } from 'date-fns'; 9 | import { useTranslation } from 'react-i18next'; 10 | 11 | export const CampaignManager: React.FC = () => { 12 | const { t } = useTranslation(); 13 | const { 14 | templates, 15 | contactLists, 16 | campaigns, 17 | smtpConfig, 18 | createCampaign, 19 | updateCampaign, 20 | deleteCampaign 21 | } = useStore(); 22 | 23 | const [newCampaignName, setNewCampaignName] = useState(''); 24 | const [selectedTemplateId, setSelectedTemplateId] = useState(''); 25 | const [selectedContactListIds, setSelectedContactListIds] = useState([]); 26 | const [subject, setSubject] = useState(''); 27 | const [showErrors, setShowErrors] = useState(null); 28 | const [dropdownOpen, setDropdownOpen] = useState(false); 29 | const dropdownRef = useRef(null); 30 | 31 | // Close dropdown when clicking outside 32 | useEffect(() => { 33 | function handleClickOutside(event: MouseEvent) { 34 | if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { 35 | setDropdownOpen(false); 36 | } 37 | } 38 | document.addEventListener("mousedown", handleClickOutside); 39 | return () => { 40 | document.removeEventListener("mousedown", handleClickOutside); 41 | }; 42 | }, []); 43 | 44 | const handleCreateCampaign = () => { 45 | if (!newCampaignName || !selectedTemplateId || selectedContactListIds.length === 0 || !subject) { 46 | toast.error(t('campaigns.messages.fillFields')); 47 | return; 48 | } 49 | 50 | const selectedTemplate = templates.find(t => t.id === selectedTemplateId); 51 | const selectedContactLists = contactLists.filter(cl => selectedContactListIds.includes(cl.id)); 52 | 53 | if (!selectedTemplate || selectedContactLists.length === 0) { 54 | toast.error(t('campaigns.messages.notFound')); 55 | return; 56 | } 57 | 58 | // Calculate total contacts across all selected contact lists 59 | const totalContacts = selectedContactLists.reduce((sum, list) => sum + list.contacts.length, 0); 60 | 61 | const campaign: Campaign = { 62 | id: Date.now().toString(), 63 | name: newCampaignName, 64 | subject, 65 | templateId: selectedTemplateId, 66 | templateName: selectedTemplate.name, 67 | contactListIds: selectedContactLists.map(list => list.id), 68 | contactListNames: selectedContactLists.map(list => list.name), 69 | status: 'draft', 70 | sentCount: 0, 71 | totalCount: totalContacts, 72 | createDate: new Date().toISOString() 73 | }; 74 | 75 | createCampaign(campaign); 76 | setNewCampaignName(''); 77 | setSelectedTemplateId(''); 78 | setSelectedContactListIds([]); 79 | setSubject(''); 80 | toast.success(t('campaigns.messages.createSuccess')); 81 | }; 82 | 83 | const handleStartCampaign = async (campaignId: string) => { 84 | const campaign = campaigns.find(c => c.id === campaignId); 85 | const template = templates.find(t => t.id === campaign?.templateId); 86 | const selectedContactLists = contactLists.filter(cl => campaign?.contactListIds.includes(cl.id)); 87 | 88 | if (!campaign || !template) { 89 | toast.error(t('campaigns.messages.notFound')); 90 | return; 91 | } 92 | 93 | // Validate that the template content is not empty 94 | if (!template.content || template.content.trim() === '') { 95 | toast.error(t('campaigns.messages.emptyTemplate')); 96 | return; 97 | } 98 | 99 | if (!smtpConfig.host) { 100 | toast.error(t('campaigns.messages.configureSmtp')); 101 | return; 102 | } 103 | 104 | if (smtpConfig.useAuth && (!smtpConfig.username || !smtpConfig.password)) { 105 | toast.error(t('campaigns.messages.smtpAuth')); 106 | return; 107 | } 108 | 109 | // Check if all contact lists have contacts 110 | const hasContacts = selectedContactLists.every(list => list.contacts.length > 0); 111 | if (!hasContacts) { 112 | toast.error(t('campaigns.messages.noContacts')); 113 | return; 114 | } 115 | 116 | updateCampaign(campaignId, { status: 'sending', sentCount: 0 }); 117 | 118 | let successCount = 0; 119 | const errors: Array<{ email: string; error: string }> = []; 120 | 121 | // Process contacts from all selected contact lists 122 | for (const contactList of selectedContactLists) { 123 | for (const contact of contactList.contacts) { 124 | try { 125 | const personalizedContent = template.content.replace(/\{\{name\}\}/g, contact.name); 126 | 127 | const result = await sendEmail( 128 | contact, 129 | campaign.subject, 130 | personalizedContent, 131 | smtpConfig 132 | ); 133 | 134 | if (result.success) { 135 | successCount++; 136 | } else { 137 | errors.push({ email: contact.email, error: result.error || 'Unknown error' }); 138 | } 139 | 140 | updateCampaign(campaignId, { 141 | sentCount: successCount, 142 | }); 143 | } catch (error: any) { 144 | errors.push({ 145 | email: contact.email, 146 | error: error.message || 'Unknown error' 147 | }); 148 | } 149 | } 150 | } 151 | 152 | const status = errors.length > 0 153 | ? (successCount === 0 ? 'failed' : 'completed with errors') 154 | : 'completed'; 155 | updateCampaign(campaignId, { 156 | status, 157 | sentCount: successCount, 158 | error: errors.length > 0 ? JSON.stringify(errors) : undefined 159 | }); 160 | 161 | if (errors.length > 0) { 162 | toast.error(t('campaigns.messages.completedWithErrors', { 0: errors.length, 1: successCount })); 163 | } else { 164 | toast.success(t('campaigns.messages.completedSuccess', { 0: successCount })); 165 | } 166 | }; 167 | 168 | const handleContactListToggle = (id: string) => { 169 | setSelectedContactListIds(prev => 170 | prev.includes(id) 171 | ? prev.filter(listId => listId !== id) 172 | : [...prev, id] 173 | ); 174 | }; 175 | 176 | const handleRemoveContactList = (id: string) => { 177 | setSelectedContactListIds(prev => prev.filter(listId => listId !== id)); 178 | }; 179 | 180 | const handlePauseCampaign = (campaignId: string) => { 181 | updateCampaign(campaignId, { status: 'draft' }); 182 | toast.success(t('campaigns.messages.pauseSuccess')); 183 | }; 184 | 185 | const handleDeleteCampaign = (campaignId: string) => { 186 | const campaign = campaigns.find(c => c.id === campaignId); 187 | if (campaign?.status === 'sending') { 188 | toast.error(t('campaigns.messages.cantDelete')); 189 | return; 190 | } 191 | deleteCampaign(campaignId); 192 | toast.success(t('campaigns.messages.deleteSuccess')); 193 | }; 194 | 195 | // Calculate total contacts in selected lists 196 | const totalSelectedContacts = contactLists 197 | .filter(cl => selectedContactListIds.includes(cl.id)) 198 | .reduce((sum, list) => sum + list.contacts.length, 0); 199 | 200 | return ( 201 |
202 |
203 |

{t('campaigns.newCampaign.title')}

204 |
205 |
206 | 207 | setNewCampaignName(e.target.value)} 211 | className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" 212 | /> 213 |
214 |
215 | 216 | setSubject(e.target.value)} 220 | className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" 221 | /> 222 |
223 |
224 | 225 | 237 |
238 | 239 | {/* Custom contact list multi-select dropdown */} 240 |
241 | 244 | 245 | {/* Dropdown container */} 246 |
247 | {/* Dropdown trigger button */} 248 | 264 | 265 | {/* Dropdown menu */} 266 | {dropdownOpen && ( 267 |
268 | {contactLists.length === 0 ? ( 269 |
270 | {t('campaigns.newCampaign.noContactLists')} 271 |
272 | ) : ( 273 | contactLists.map(list => ( 274 |
handleContactListToggle(list.id)} 277 | className={`cursor-pointer select-none relative py-2 pl-3 pr-9 hover:bg-indigo-50 ${ 278 | selectedContactListIds.includes(list.id) ? 'bg-indigo-50' : '' 279 | }`} 280 | > 281 |
282 |
283 | {selectedContactListIds.includes(list.id) && ( 284 | 285 | )} 286 |
287 | 288 | {list.name} ({list.contacts.length} {t('campaigns.newCampaign.contactCount')}) 289 | 290 |
291 |
292 | )) 293 | )} 294 |
295 | )} 296 |
297 | 298 | {/* Selected contact list tags */} 299 | {selectedContactListIds.length > 0 && ( 300 |
301 |
302 | {selectedContactListIds.map(id => { 303 | const list = contactLists.find(cl => cl.id === id); 304 | return list ? ( 305 |
309 | {list.name} ({list.contacts.length}) 310 | 317 |
318 | ) : null; 319 | })} 320 |
321 |
322 | {t('campaigns.newCampaign.totalContacts')}: {totalSelectedContacts} 323 |
324 |
325 | )} 326 |
327 | 328 | 334 |
335 |
336 | 337 | {/* Campaign table section */} 338 |
339 |
340 | 341 | 342 | 343 | 346 | 349 | 352 | 355 | 358 | 361 | {/* Actions column is always visible */} 362 | 365 | 366 | 367 | 368 | {campaigns.map((campaign) => { 369 | const parsedDate = campaign.createDate 370 | ? parse(campaign.createDate, 'dd/MM/yyyy HH:mm:ss', new Date()) 371 | : null; 372 | 373 | // Handle displaying multiple contact list names - fixed to handle legacy data 374 | const contactListsDisplay = Array.isArray(campaign.contactListNames) 375 | ? campaign.contactListNames.join(', ') 376 | : (campaign as any).contactListName || ''; // Safe cast for backward compatibility 377 | 378 | return ( 379 | 380 | 384 | 392 | 395 | 398 | 424 | 427 | {/* Actions column is always visible and styled to appear on top of other content when scrolling horizontally */} 428 | 469 | 470 | ); 471 | })} 472 | 473 |
344 | {t('campaigns.table.campaign')} 345 | 347 | {t('campaigns.table.date')} 348 | 350 | {t('campaigns.table.template')} 351 | 353 | {t('campaigns.table.contactList')} 354 | 356 | {t('campaigns.table.status')} 357 | 359 | {t('campaigns.table.progress')} 360 | 363 | {t('campaigns.table.actions')} 364 |
381 |
{campaign.name}
382 |
{campaign.subject}
383 |
385 |
386 | {parsedDate && parsedDate?.getTime() > 0 ? format(parsedDate, 'dd/MM/yyyy') : '-'} 387 |
388 |
389 | {parsedDate && parsedDate?.getTime() > 0 ? format(parsedDate, 'HH:mm:ss') : '-'} 390 |
391 |
393 | {campaign.templateName} 394 | 396 | {contactListsDisplay} 397 | 399 | 412 | {t(`campaigns.status.${campaign.status.replace(/\s+/g, '')}`)} 413 | 414 | {campaign.error && ( 415 | 422 | )} 423 | 425 | {campaign.sentCount} / {campaign.totalCount} 426 | 429 | {campaign.status === 'draft' && ( 430 | 437 | )} 438 | {campaign.status === 'sending' && ( 439 | 446 | )} 447 | {campaign.status === 'completed' && ( 448 | 449 | )} 450 | {campaign.status === 'failed' && ( 451 | 458 | )} 459 | {campaign.status !== 'sending' && ( 460 | 467 | )} 468 |
474 |
475 |
476 | 477 | {showErrors && ( 478 | c.id === showErrors)?.error || '[]')} 480 | onClose={() => setShowErrors(null)} 481 | /> 482 | )} 483 |
484 | ); 485 | }; -------------------------------------------------------------------------------- /src/components/ContactsUploader.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef } from 'react'; 2 | import { Upload } from 'lucide-react'; 3 | import { useStore } from '../store/useStore'; 4 | import { EmailContact } from '../types'; 5 | import toast from 'react-hot-toast'; 6 | import { useTranslation } from 'react-i18next'; 7 | 8 | export const ContactsUploader: React.FC = () => { 9 | const { t } = useTranslation(); 10 | const fileInputRef = useRef(null); 11 | const { importContacts } = useStore(); 12 | 13 | const handleFileUpload = (event: React.ChangeEvent) => { 14 | const file = event.target.files?.[0]; 15 | if (!file) return; 16 | 17 | const reader = new FileReader(); 18 | reader.onload = (e) => { 19 | try { 20 | const text = e.target?.result as string; 21 | const contacts: EmailContact[] = text 22 | .split('\n') 23 | .filter(Boolean) 24 | .map((line) => { 25 | const [name, email] = line.split(';').map((s) => s.trim()); 26 | return { 27 | id: Date.now().toString() + Math.random().toString(36).substr(2, 9), 28 | name, 29 | email 30 | }; 31 | }); 32 | importContacts(contacts); 33 | toast.success(t('contacts.uploader.messages.success', { 0: contacts.length })); 34 | } catch (error) { 35 | toast.error(t('contacts.uploader.messages.error')); 36 | } 37 | }; 38 | reader.readAsText(file); 39 | }; 40 | 41 | return ( 42 |
43 | 50 | 57 |

58 | {t('contacts.uploader.format')} 59 |

60 |
61 | ); 62 | }; -------------------------------------------------------------------------------- /src/components/ErrorDetails.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { X } from 'lucide-react'; 3 | import { useTranslation } from 'react-i18next'; 4 | 5 | interface Props { 6 | errors: Array<{ email: string; error: string }>; 7 | onClose: () => void; 8 | } 9 | 10 | export const ErrorDetails: React.FC = ({ errors, onClose }) => { 11 | const { t } = useTranslation(); 12 | 13 | return ( 14 |
15 |
16 |
17 |

{t('campaigns.errors.title')}

18 | 25 |
26 |
27 | 28 | 29 | 30 | 33 | 36 | 37 | 38 | 39 | {errors.map((error, index) => ( 40 | 41 | 44 | 47 | 48 | ))} 49 | 50 |
31 | {t('campaigns.errors.emailColumn')} 32 | 34 | {t('campaigns.errors.errorColumn')} 35 |
42 | {error.email} 43 | 45 | {error.error} 46 |
51 |
52 |
53 |
54 | ); 55 | }; -------------------------------------------------------------------------------- /src/components/Navbar.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import logo from '../icon.png'; 3 | import { useTranslation } from 'react-i18next'; 4 | import { changeLanguage } from '../i18n'; 5 | import { getAvailableLanguages } from '../utils/languageUtils'; 6 | 7 | const normalizeLangCode = (lang: string) => lang.split('-')[0]; 8 | 9 | export const Navbar: React.FC = () => { 10 | const { i18n } = useTranslation(); 11 | const languages = getAvailableLanguages(); 12 | const [currentLanguage, setCurrentLanguage] = useState( 13 | normalizeLangCode(i18n.language) 14 | ); 15 | 16 | useEffect(() => { 17 | const savedLang = localStorage.getItem('userLanguage'); 18 | 19 | if (savedLang) { 20 | const normalizedSavedLang = normalizeLangCode(savedLang); 21 | setCurrentLanguage(normalizedSavedLang); 22 | changeLanguage(normalizedSavedLang); 23 | } else { 24 | const browserLanguages = navigator.languages.map(normalizeLangCode); 25 | const availableLangs = Object.keys(languages); 26 | const preferredLang = browserLanguages.find(lang => availableLangs.includes(lang)); 27 | 28 | if (preferredLang) { 29 | setCurrentLanguage(preferredLang); 30 | changeLanguage(preferredLang); 31 | } 32 | } 33 | }, []); 34 | 35 | const handleLanguageChange = (e: React.ChangeEvent) => { 36 | const selectedLang = e.target.value; 37 | const normalizedLang = normalizeLangCode(selectedLang); 38 | 39 | setCurrentLanguage(normalizedLang); 40 | changeLanguage(normalizedLang); 41 | localStorage.setItem('userLanguage', normalizedLang); 42 | }; 43 | 44 | 45 | return ( 46 | 74 | ); 75 | }; -------------------------------------------------------------------------------- /src/components/Navigation.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { NavLink, useLocation } from 'react-router-dom'; 3 | import { Settings, Send, FileText, Users } from 'lucide-react'; 4 | import { useTranslation } from 'react-i18next'; 5 | 6 | export const Navigation: React.FC = () => { 7 | const location = useLocation(); 8 | const currentPath = location.pathname; 9 | const { t } = useTranslation(); 10 | 11 | return ( 12 |
13 | 21 | 22 | {t('templates.title')} 23 | 24 | 32 | 33 | {t('contacts.title')} 34 | 35 | 43 | 44 | {t('campaigns.title')} 45 | 46 | 54 | 55 | {t('settings.title')} 56 | 57 |
58 | ); 59 | }; -------------------------------------------------------------------------------- /src/components/TemplateEditor.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import Editor from '@monaco-editor/react'; 3 | import { useStore } from '../store/useStore'; 4 | import { RichTextEditor } from './editor/RichTextEditor'; 5 | import { Code, Eye } from 'lucide-react'; 6 | import { useTranslation } from 'react-i18next'; 7 | import toast from 'react-hot-toast'; 8 | 9 | interface Props { 10 | templateId: string; 11 | } 12 | 13 | export const TemplateEditor: React.FC = ({ templateId }) => { 14 | const { templates, updateTemplate } = useStore(); 15 | const template = templates.find((t) => t.id === templateId); 16 | const [mode, setMode] = useState<'visual' | 'code'>('code'); 17 | const [name, setName] = useState(''); 18 | const [content, setContent] = useState(''); 19 | const { t } = useTranslation(); 20 | 21 | useEffect(() => { 22 | if (template) { 23 | setName(template.name); 24 | setContent(template.content); 25 | } 26 | }, [template]); 27 | 28 | if (!template) return null; 29 | 30 | const handleNameChange = async (newName: string) => { 31 | try { 32 | setName(newName); 33 | await updateTemplate(templateId, { name: newName }); 34 | } catch (error) { 35 | toast.error(t('templates.messages.failedSave')); 36 | } 37 | }; 38 | 39 | const handleContentChange = async (newContent: string) => { 40 | try { 41 | setContent(newContent); 42 | await updateTemplate(templateId, { content: newContent }); 43 | } catch (error) { 44 | toast.error(t('templates.messages.failedSave')); 45 | } 46 | }; 47 | 48 | const previewContent = ` 49 | 50 | 51 | 55 | 56 | 57 |
58 | ${content} 59 |
60 | 61 | 62 | `; 63 | 64 | return ( 65 |
66 |
67 | handleNameChange(e.target.value)} 71 | className="flex-1 px-4 py-2 border rounded-md" 72 | placeholder={t('templates.editor.templateNamePlaceholder')} 73 | aria-label={t('templates.editor.templateName')} 74 | /> 75 |
76 | 85 | 94 |
95 |
96 | 97 | {mode === 'visual' ? ( 98 | 102 | ) : ( 103 | handleContentChange(value || '')} 108 | theme="vs-light" 109 | options={{ 110 | minimap: { enabled: false }, 111 | fontSize: 14, 112 | wordWrap: 'on', 113 | }} 114 | /> 115 | )} 116 | 117 |
118 |

Preview

119 |