├── .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 |
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 |
344 | {t('campaigns.table.campaign')}
345 | |
346 |
347 | {t('campaigns.table.date')}
348 | |
349 |
350 | {t('campaigns.table.template')}
351 | |
352 |
353 | {t('campaigns.table.contactList')}
354 | |
355 |
356 | {t('campaigns.table.status')}
357 | |
358 |
359 | {t('campaigns.table.progress')}
360 | |
361 | {/* Actions column is always visible */}
362 |
363 | {t('campaigns.table.actions')}
364 | |
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 |
381 | {campaign.name}
382 | {campaign.subject}
383 | |
384 |
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 | |
392 |
393 | {campaign.templateName}
394 | |
395 |
396 | {contactListsDisplay}
397 | |
398 |
399 |
412 | {t(`campaigns.status.${campaign.status.replace(/\s+/g, '')}`)}
413 |
414 | {campaign.error && (
415 |
422 | )}
423 | |
424 |
425 | {campaign.sentCount} / {campaign.totalCount}
426 | |
427 | {/* Actions column is always visible and styled to appear on top of other content when scrolling horizontally */}
428 |
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 | |
469 |
470 | );
471 | })}
472 |
473 |
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 |
31 | {t('campaigns.errors.emailColumn')}
32 | |
33 |
34 | {t('campaigns.errors.errorColumn')}
35 | |
36 |
37 |
38 |
39 | {errors.map((error, index) => (
40 |
41 |
42 | {error.email}
43 | |
44 |
45 | {error.error}
46 | |
47 |
48 | ))}
49 |
50 |
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 |
124 |
125 |
126 | );
127 | };
--------------------------------------------------------------------------------
/src/components/contacts/AddContactForm.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useRef } from 'react';
2 | import { EmailContact } from '../../types';
3 | import { parseCSV } from '../../utils/csvParser';
4 | import { Upload } from 'lucide-react';
5 | import toast from 'react-hot-toast';
6 | import { useTranslation } from 'react-i18next';
7 |
8 | interface Props {
9 | onAdd: (contacts: EmailContact[]) => void;
10 | onCancel: () => void;
11 | }
12 |
13 | export const AddContactForm: React.FC = ({ onAdd, onCancel }) => {
14 | const { t } = useTranslation();
15 | const [name, setName] = useState('');
16 | const [email, setEmail] = useState('');
17 | const [csvContent, setCsvContent] = useState('');
18 | const fileInputRef = useRef(null);
19 |
20 | const handleSubmit = (e: React.FormEvent) => {
21 | e.preventDefault();
22 |
23 | if (csvContent) {
24 | try {
25 | const contacts = parseCSV(csvContent);
26 | onAdd(contacts);
27 | setCsvContent('');
28 | toast.success('Contacts imported successfully');
29 | } catch (error: any) {
30 | toast.error(error.message);
31 | }
32 | return;
33 | }
34 |
35 | if (!name.trim() || !email.trim()) {
36 | toast.error(t('contacts.addContact.validation.nameEmail'));
37 | return;
38 | }
39 |
40 | if (!email.includes('@')) {
41 | toast.error(t('contacts.addContact.validation.validEmail'));
42 | return;
43 | }
44 |
45 | const newContact: EmailContact = {
46 | id: Date.now().toString() + Math.random().toString(36).substr(2, 9),
47 | name: name.trim(),
48 | email: email.trim(),
49 | };
50 |
51 | onAdd([newContact]);
52 | setName('');
53 | setEmail('');
54 | // Toast message will be shown by parent component
55 | };
56 |
57 | const handleFileUpload = (event: React.ChangeEvent) => {
58 | const file = event.target.files?.[0];
59 | if (!file) return;
60 |
61 | const reader = new FileReader();
62 | reader.onload = (e) => {
63 | try {
64 | const content = e.target?.result as string;
65 | setCsvContent(content);
66 | toast.success('CSV file loaded successfully');
67 | } catch (error) {
68 | toast.error('Error reading CSV file');
69 | }
70 | };
71 | reader.readAsText(file);
72 | };
73 |
74 | return (
75 |
166 | );
167 | };
--------------------------------------------------------------------------------
/src/components/contacts/ContactListDetails.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import { ContactList, EmailContact } from '../../types';
3 | import { Pencil, Trash2, Save, X, UserPlus, Download } from 'lucide-react';
4 | import { AddContactForm } from './AddContactForm';
5 | import toast from 'react-hot-toast';
6 | import { useTranslation } from 'react-i18next';
7 |
8 | interface Props {
9 | contactList: ContactList;
10 | onUpdate: (contacts: EmailContact[]) => void;
11 | onDelete: () => void;
12 | }
13 |
14 | export const ContactListDetails: React.FC = ({
15 | contactList,
16 | onUpdate,
17 | onDelete,
18 | }) => {
19 | const { t } = useTranslation();
20 | const [editingId, setEditingId] = useState(null);
21 | const [editForm, setEditForm] = useState({ id: '', name: '', email: '' });
22 | const [isAddingContact, setIsAddingContact] = useState(false);
23 |
24 | const handleEdit = (contact: EmailContact) => {
25 | setEditingId(contact.id);
26 | setEditForm(contact);
27 | };
28 |
29 | const handleSave = () => {
30 | if (!editForm.name || !editForm.email) {
31 | toast.error(t('contacts.addContact.validation.nameEmail'));
32 | return;
33 | }
34 |
35 | if (!editForm.email.includes('@')) {
36 | toast.error(t('contacts.addContact.validation.validEmail'));
37 | return;
38 | }
39 |
40 | const updatedContacts = contactList.contacts.map((c) =>
41 | c.id === editForm.id ? editForm : c
42 | );
43 | onUpdate(updatedContacts);
44 | setEditingId(null);
45 | toast.success(t('contacts.list.messages.updateSuccess'));
46 | };
47 |
48 | const handleDelete = (id: string) => {
49 | const updatedContacts = contactList.contacts.filter((c) => c.id !== id);
50 | onUpdate(updatedContacts);
51 | toast.success(t('contacts.list.messages.deleteContactSuccess'));
52 | };
53 |
54 | const handleAddContacts = (newContacts: EmailContact[]) => {
55 | if (newContacts.length === 1 && contactList.contacts.some(existing =>
56 | existing.email.toLowerCase() === newContacts[0].email.toLowerCase()
57 | )) {
58 | toast.error(t('contacts.list.messages.duplicateEmail'), {duration: 5000});
59 | return;
60 | }
61 | else
62 | {
63 | // Remove duplicates and contacts that already exist in contactList
64 | const uniqueNewContacts = newContacts.filter(
65 | newContact => !contactList.contacts.some(
66 | existing => existing.email.toLowerCase() === newContact.email.toLowerCase()
67 | ) && newContacts.findIndex(
68 | c => c.email.toLowerCase() === newContact.email.toLowerCase()
69 | ) === newContacts.indexOf(newContact)
70 | );
71 | if (uniqueNewContacts.length !== newContacts.length) {
72 | toast('Duplicate or existing email addresses were removed', {
73 | icon: '⚠️',
74 | duration: 5000,
75 | });
76 | }
77 | newContacts = uniqueNewContacts;
78 | }
79 | const updatedContacts = [...contactList.contacts, ...newContacts];
80 | onUpdate(updatedContacts);
81 | setIsAddingContact(false);
82 | if (newContacts.length === 1) {
83 | toast.success(t('contacts.list.messages.contactAdded'));
84 | }
85 | };
86 |
87 | const handleExportCSV = () => {
88 | try {
89 | // Generar contenido CSV a partir de la lista de contactos
90 | const csvContent = contactList.contacts
91 | .map(contact => `${contact.name};${contact.email}`)
92 | .join('\n');
93 |
94 | // Crear un blob con el contenido CSV
95 | const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
96 |
97 | // Crear un objeto URL para el blob
98 | const url = URL.createObjectURL(blob);
99 |
100 | // Crear un enlace temporal para la descarga
101 | const link = document.createElement('a');
102 | link.href = url;
103 | link.setAttribute('download', `${contactList.name}.csv`);
104 | document.body.appendChild(link);
105 |
106 | // Simular clic en el enlace para iniciar la descarga
107 | link.click();
108 |
109 | // Limpiar después de la descarga
110 | document.body.removeChild(link);
111 | URL.revokeObjectURL(url);
112 |
113 | toast.success(t('contacts.list.messages.exportSuccess') || 'Lista de contactos exportada con éxito');
114 | } catch (error: any) {
115 | toast.error(error.message);
116 | }
117 | };
118 |
119 | return (
120 |
121 |
122 |
{contactList.name}
123 |
124 |
132 |
139 |
140 |
141 |
142 | {isAddingContact && (
143 |
setIsAddingContact(false)}
146 | />
147 | )}
148 |
149 |
233 |
234 |
240 |
241 |
242 | );
243 | };
--------------------------------------------------------------------------------
/src/components/contacts/ContactListForm.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useRef } from 'react';
2 | import { EmailContact } from '../../types';
3 | import { parseCSV } from '../../utils/csvParser';
4 | import { Upload, Download } from 'lucide-react';
5 | import toast from 'react-hot-toast';
6 | import { useTranslation } from 'react-i18next';
7 |
8 | interface Props {
9 | onSave: (name: string, contacts: EmailContact[]) => void;
10 | onCancel: () => void;
11 | }
12 |
13 | export const ContactListForm: React.FC = ({ onSave, onCancel }) => {
14 | const { t } = useTranslation();
15 | const [name, setName] = useState('');
16 | const [csvContent, setCsvContent] = useState('');
17 | const fileInputRef = useRef(null);
18 |
19 | const handleFileUpload = (event: React.ChangeEvent) => {
20 | const file = event.target.files?.[0];
21 | if (!file) return;
22 |
23 | const reader = new FileReader();
24 | reader.onload = (e) => {
25 | try {
26 | const content = e.target?.result as string;
27 | setCsvContent(content);
28 | toast.success(t('contacts.uploader.messages.success'));
29 | } catch (error) {
30 | toast.error(t('contacts.uploader.messages.error'));
31 | }
32 | };
33 | reader.readAsText(file);
34 | };
35 |
36 | const handleSubmit = (e: React.FormEvent) => {
37 | e.preventDefault();
38 |
39 | if (!name.trim()) {
40 | toast.error(t('contacts.list.messages.nameRequired'));
41 | return;
42 | }
43 |
44 | if (!csvContent.trim()) {
45 | toast.error(t('contacts.list.messages.dataRequired'));
46 | return;
47 | }
48 |
49 | try {
50 | const contacts = parseCSV(csvContent);
51 | onSave(name, contacts);
52 | toast.success(t('contacts.list.messages.listCreated'));
53 | } catch (error: any) {
54 | toast.error(error.message);
55 | }
56 | };
57 |
58 | const handleExportCSV = () => {
59 | if (!csvContent.trim()) {
60 | toast.error(t('contacts.list.messages.dataRequired'));
61 | return;
62 | }
63 |
64 | try {
65 | // Crear un blob con el contenido CSV
66 | const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
67 |
68 | // Crear un objeto URL para el blob
69 | const url = URL.createObjectURL(blob);
70 |
71 | // Crear un enlace temporal para la descarga
72 | const link = document.createElement('a');
73 | link.href = url;
74 | link.setAttribute('download', `${name || 'contacts'}.csv`);
75 | document.body.appendChild(link);
76 |
77 | // Simular clic en el enlace para iniciar la descarga
78 | link.click();
79 |
80 | // Limpiar después de la descarga
81 | document.body.removeChild(link);
82 | URL.revokeObjectURL(url);
83 |
84 | toast.success(t('contacts.list.messages.exportSuccess') || 'Lista de contactos exportada con éxito');
85 | } catch (error: any) {
86 | toast.error(error.message);
87 | }
88 | };
89 |
90 | return (
91 |
167 | );
168 | };
--------------------------------------------------------------------------------
/src/components/contacts/ContactListSelector.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { ContactList } from '../../types';
3 | import { PlusCircle } from 'lucide-react';
4 | import { useTranslation } from 'react-i18next';
5 |
6 | interface Props {
7 | contactLists: ContactList[];
8 | selectedListId: string | null;
9 | onSelect: (id: string) => void;
10 | onCreateNew: () => void;
11 | }
12 |
13 | export const ContactListSelector: React.FC = ({
14 | contactLists,
15 | selectedListId,
16 | onSelect,
17 | onCreateNew,
18 | }) => {
19 | const { t } = useTranslation();
20 |
21 | return (
22 |
23 |
24 |
{t('contacts.list.title')}
25 |
32 |
33 |
34 | {contactLists.map((list) => (
35 |
49 | ))}
50 |
51 |
52 | );
53 | };
--------------------------------------------------------------------------------
/src/components/editor/RichTextEditor.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useEditor, EditorContent } from '@tiptap/react';
3 | import StarterKit from '@tiptap/starter-kit';
4 | import Link from '@tiptap/extension-link';
5 | import { Bold, Italic, List, ListOrdered, Link as LinkIcon, Heading } from 'lucide-react';
6 | import { useTranslation } from 'react-i18next';
7 |
8 | interface Props {
9 | content: string;
10 | onChange: (content: string) => void;
11 | }
12 |
13 | export const RichTextEditor: React.FC = ({ content, onChange }) => {
14 | const { t } = useTranslation();
15 | const editor = useEditor({
16 | extensions: [
17 | StarterKit,
18 | Link.configure({
19 | openOnClick: false,
20 | }),
21 | ],
22 | content,
23 | onUpdate: ({ editor }) => {
24 | onChange(editor.getHTML());
25 | },
26 | });
27 |
28 | if (!editor) {
29 | return null;
30 | }
31 |
32 | return (
33 |
34 |
35 |
44 |
53 |
62 |
71 |
85 |
94 |
95 |
96 |
97 | );
98 | };
--------------------------------------------------------------------------------
/src/components/editor/TemplateList.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useStore } from '../../store/useStore';
3 | import { Trash2 } from 'lucide-react';
4 | import toast from 'react-hot-toast';
5 | import { useTranslation } from 'react-i18next';
6 |
7 | interface Props {
8 | selectedId: string | null;
9 | onSelect: (id: string) => void;
10 | }
11 |
12 | export const TemplateList: React.FC = ({ selectedId, onSelect }) => {
13 | const { templates, deleteTemplate } = useStore();
14 | const { t } = useTranslation();
15 |
16 | const handleDelete = (id: string) => {
17 | if (window.confirm(t('templates.list.deleteConfirm'))) {
18 | deleteTemplate(id);
19 | if (selectedId === id) {
20 | onSelect(templates[0]?.id || '');
21 | }
22 | toast.success(t('templates.list.deleteSuccess'));
23 | }
24 | };
25 |
26 | return (
27 |
28 |
29 | {templates.map((template) => (
30 |
onSelect(template.id)}
36 | >
37 | {template.name}
38 |
47 |
48 | ))}
49 |
50 |
51 | );
52 | };
--------------------------------------------------------------------------------
/src/components/tabs/CampaignTab.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useTranslation } from 'react-i18next';
3 | import { CampaignManager } from '../CampaignManager';
4 |
5 |
6 | export const CampaignTab: React.FC = () => {
7 | const { t } = useTranslation();
8 | return (
9 |
10 |
{t('campaigns.title')}
11 |
12 |
13 | );
14 | };
--------------------------------------------------------------------------------
/src/components/tabs/ContactsTab.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import { useStore } from '../../store/useStore';
3 | import { ContactListSelector } from '../contacts/ContactListSelector';
4 | import { ContactListForm } from '../contacts/ContactListForm';
5 | import { ContactListDetails } from '../contacts/ContactListDetails';
6 | import { EmailContact } from '../../types';
7 | import toast from 'react-hot-toast';
8 | import { useTranslation } from 'react-i18next';
9 |
10 | export const ContactsTab: React.FC = () => {
11 | const { contactLists, addContactList, updateContactList, deleteContactList } = useStore();
12 | const [selectedListId, setSelectedListId] = useState(null);
13 | const [isCreating, setIsCreating] = useState(false);
14 | const { t } = useTranslation();
15 |
16 | const handleCreateList = (name: string, contacts: EmailContact[]) => {
17 | const newList = {
18 | id: Date.now().toString(),
19 | name,
20 | contacts,
21 | };
22 | addContactList(newList);
23 | setSelectedListId(newList.id);
24 | setIsCreating(false);
25 | };
26 |
27 | const handleUpdateContacts = (contacts: EmailContact[]) => {
28 | if (selectedListId) {
29 | updateContactList(selectedListId, { contacts });
30 | }
31 | };
32 |
33 | const handleDeleteList = () => {
34 | if (selectedListId) {
35 | deleteContactList(selectedListId);
36 | setSelectedListId(null);
37 | toast.success(t('contacts.list.messages.deleteSuccess'));
38 | }
39 | };
40 |
41 | const selectedList = contactLists.find((list) => list.id === selectedListId);
42 |
43 | return (
44 |
45 |
{t('contacts.list.title')}
46 |
47 | {/* Left side with scrollable contact lists */}
48 |
49 | setIsCreating(true)}
54 | />
55 |
56 |
57 | {/* Right side with fixed position content */}
58 |
59 | {isCreating ? (
60 |
setIsCreating(false)}
63 | />
64 | ) : selectedList ? (
65 |
70 | ) : (
71 |
72 | {t('contacts.list.messages.selectContactList')}
73 |
74 | )}
75 |
76 |
77 |
78 | );
79 | };
--------------------------------------------------------------------------------
/src/components/tabs/SettingsTab.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import { useStore } from '../../store/useStore';
3 | import { useTranslation } from 'react-i18next';
4 | import toast from 'react-hot-toast';
5 | import { SmtpConfig } from '../../types';
6 | import { Loader2 } from 'lucide-react'; // Import from lucide-react for the spinner
7 |
8 |
9 | export const SettingsTab: React.FC = () => {
10 | const { smtpConfig, updateSmtpConfig, saveSettings, isLoading, error } = useStore();
11 | const [formData, setFormData] = useState(smtpConfig);
12 | const { t } = useTranslation();
13 |
14 | // Constants for SMTP ports
15 | const SSL_PORT = 465;
16 | const NON_SSL_PORT = 587;
17 |
18 | const handleInputChange = (
19 | e: React.ChangeEvent
20 | ) => {
21 | const target = e.target as HTMLInputElement;
22 | const { name } = target;
23 |
24 | if (target.type === 'checkbox' && name === 'useSSL') {
25 | const isSSL = target.checked;
26 | setFormData(prev => ({
27 | ...prev,
28 | [name]: target.checked,
29 | port: isSSL ? SSL_PORT : NON_SSL_PORT
30 | }));
31 | }
32 | else if (name === 'useAuth') {
33 | setFormData(prev => ({
34 | ...prev,
35 | [name]: target.checked,
36 | ...(target.checked === false && {
37 | username: '',
38 | password: ''
39 | })
40 | }));
41 | }
42 | else {
43 | const value = target.type === 'number' ? parseInt(target.value) : target.value;
44 | setFormData(prev => ({
45 | ...prev,
46 | [name]: value
47 | }));
48 | }
49 | };
50 |
51 | const handleSubmit = async () => {
52 | try {
53 | await updateSmtpConfig(formData);
54 | await saveSettings();
55 | toast.success(t('settings.toast.success'));
56 | } catch (error) {
57 | console.error('Failed to save settings:', error);
58 | toast.error(t('settings.toast.error'));
59 | }
60 | };
61 |
62 | return (
63 |
64 |
{t('settings.smtp.title')}
65 | {error && (
66 |
67 | {error}
68 |
69 | )}
70 |
71 |
72 |
73 |
76 |
83 |
84 |
85 |
86 |
87 |
90 |
97 |
98 | {formData.useSSL ? t('settings.smtp.sslPortMessage') : t('settings.smtp.tlsPortMessage')}
99 |
100 |
101 |
111 |
112 |
113 |
114 |
117 |
124 |
125 |
126 |
129 |
136 |
137 |
138 |
139 |
149 |
{t('settings.smtp.smtpAuthWarning')}
150 |
151 |
152 | {Boolean(formData.useAuth) && (
153 |
179 | )}
180 |
181 |
182 |
183 |
195 |
196 |
197 |
198 | );
199 | };
--------------------------------------------------------------------------------
/src/components/tabs/TemplatesTab.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import { TemplateEditor } from '../TemplateEditor';
3 | import { TemplateList } from '../editor/TemplateList';
4 | import { useStore } from '../../store/useStore';
5 | import { Upload } from 'lucide-react';
6 | import toast from 'react-hot-toast';
7 | import { useTranslation } from 'react-i18next';
8 |
9 | export const TemplatesTab: React.FC = () => {
10 | const [selectedTemplateId, setSelectedTemplateId] = useState(null);
11 | const { addTemplate } = useStore();
12 | const { t } = useTranslation();
13 |
14 | const handleFileUpload = (event: React.ChangeEvent) => {
15 | const file = event.target.files?.[0];
16 | if (!file) return;
17 |
18 | const reader = new FileReader();
19 | reader.onload = (e) => {
20 | const content = e.target?.result as string;
21 | const id = Date.now().toString();
22 | const newTemplate = {
23 | id,
24 | name: file.name.replace('.html', ''),
25 | content,
26 | };
27 | addTemplate(newTemplate);
28 | setSelectedTemplateId(id);
29 | toast.success(t('templates.messages.uploadSuccess'));
30 | };
31 | reader.readAsText(file);
32 | };
33 |
34 | const handleCreateTemplate = () => {
35 | const id = Date.now().toString();
36 | const newTemplate = {
37 | id,
38 | name: 'New Template',
39 | content: '',
40 | };
41 | addTemplate(newTemplate);
42 | setSelectedTemplateId(id);
43 | toast.success(t('templates.messages.createSuccess'));
44 | };
45 |
46 | return (
47 |
48 |
49 |
{t('templates.emailTemplates')}
50 |
51 |
61 |
67 |
68 |
69 |
70 |
71 |
72 |
76 |
77 |
78 | {selectedTemplateId ? (
79 |
80 | ) : (
81 |
82 | {t('templates.selectOrCreate')}
83 |
84 | )}
85 |
86 |
87 |
88 | );
89 | };
--------------------------------------------------------------------------------
/src/i18n.ts:
--------------------------------------------------------------------------------
1 | import i18n from 'i18next';
2 | import { initReactI18next } from 'react-i18next';
3 | import LanguageDetector from 'i18next-browser-languagedetector';
4 |
5 | // Dynamic import of all language files
6 | const importLocales = import.meta.glob('./locales/*.json', { eager: true });
7 |
8 | // Build resources object dynamically
9 | const resources = Object.keys(importLocales).reduce((acc, path) => {
10 | const langCode = path.match(/\.\/locales\/(\w+)\.json$/)?.[1];
11 | if (langCode) {
12 | acc[langCode] = {
13 | translation: (importLocales[path] as { default: any }).default
14 | };
15 | }
16 | return acc;
17 | }, {} as { [key: string]: { translation: any } });
18 |
19 | i18n
20 | .use(LanguageDetector)
21 | .use(initReactI18next)
22 | .init({
23 | resources,
24 | fallbackLng: 'en',
25 | debug: process.env.NODE_ENV === 'development',
26 | interpolation: {
27 | escapeValue: false
28 | }
29 | });
30 |
31 | // Utility function to change language
32 | export const changeLanguage = (lng: string) => {
33 | return i18n.changeLanguage(lng);
34 | };
35 |
36 | export default i18n;
--------------------------------------------------------------------------------
/src/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/garanda21/geoposler/c7a0ad3438c962f6a911df39a7cd7b0289dd6ae5/src/icon.png
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
--------------------------------------------------------------------------------
/src/locales/de.json:
--------------------------------------------------------------------------------
1 | {
2 | "templates": {
3 | "title": "Vorlagen",
4 | "emailTemplates": "E-Mail-Vorlagen",
5 | "uploadTemplate": "Vorlage hochladen",
6 | "newTemplate": "Neue Vorlage",
7 | "selectOrCreate": "Wählen Sie eine Vorlage aus oder erstellen Sie eine neue",
8 | "messages": {
9 | "uploadSuccess": "Vorlage erfolgreich hochgeladen",
10 | "createSuccess": "Neue Vorlage erstellt",
11 | "failedSave": "Fehler beim Speichern der Vorlage, bitte überprüfen Sie die Protokolle"
12 | },
13 | "editor": {
14 | "templateName": "Vorlagenname",
15 | "templateNamePlaceholder": "Vorlagennamen eingeben",
16 | "visualMode": "Visueller Modus",
17 | "codeMode": "Code-Modus",
18 | "preview": "Vorschau",
19 | "richText": {
20 | "bold": "Fett",
21 | "italic": "Kursiv",
22 | "bulletList": "Aufzählung",
23 | "orderedList": "Nummerierte Liste",
24 | "link": "Link einfügen",
25 | "heading": "Überschrift",
26 | "enterUrl": "URL eingeben"
27 | }
28 | },
29 | "list": {
30 | "deleteSuccess": "Vorlage erfolgreich gelöscht",
31 | "deleteConfirm": "Möchten Sie diese Vorlage wirklich löschen?"
32 | }
33 | },
34 | "contacts": {
35 | "title": "Kontakte",
36 | "list": {
37 | "title": "Kontaktlisten",
38 | "newList": "Neue Liste",
39 | "contactCount": "{0} Kontakte",
40 | "columns": {
41 | "name": "Name",
42 | "email": "E-Mail",
43 | "actions": "Aktionen"
44 | },
45 | "actions": {
46 | "edit": "Bearbeiten",
47 | "delete": "Löschen",
48 | "save": "Speichern",
49 | "cancel": "Abbrechen",
50 | "addContact": "Kontakt hinzufügen",
51 | "deleteList": "Liste löschen",
52 | "exportCSV": "CSV exportieren"
53 | },
54 | "form": {
55 | "listName": "Listenname",
56 | "listNamePlaceholder": "Meine Kontaktliste",
57 | "saveList": "Liste speichern"
58 | },
59 | "messages": {
60 | "selectContactList": "Wählen Sie eine Kontaktliste aus oder erstellen Sie eine neue",
61 | "deleteSuccess": "Kontaktliste erfolgreich gelöscht",
62 | "updateSuccess": "Kontakt erfolgreich aktualisiert",
63 | "deleteContactSuccess": "Kontakt erfolgreich gelöscht",
64 | "duplicateEmail": "E-Mail-Adresse existiert bereits",
65 | "duplicatesRemoved": "Doppelte oder vorhandene E-Mail-Adressen wurden entfernt",
66 | "nameRequired": "Bitte geben Sie einen Listennamen ein",
67 | "dataRequired": "Bitte geben Sie Kontaktdaten ein oder laden Sie eine CSV-Datei hoch",
68 | "listCreated": "Kontaktliste erfolgreich erstellt",
69 | "contactAdded": "Kontakt erfolgreich hinzugefügt",
70 | "exportSuccess": "Kontaktliste erfolgreich exportiert"
71 | }
72 | },
73 | "addContact": {
74 | "title": "Kontakt hinzufügen",
75 | "name": "Name",
76 | "namePlaceholder": "Max Mustermann",
77 | "email": "E-Mail",
78 | "emailPlaceholder": "max@beispiel.de",
79 | "importTitle": "Oder mehrere Kontakte importieren",
80 | "validation": {
81 | "nameEmail": "Name und E-Mail sind erforderlich",
82 | "validEmail": "Bitte geben Sie eine gültige E-Mail-Adresse ein"
83 | },
84 | "uploadCSV": "CSV-Datei hochladen",
85 | "csvFormat": "Format: Name;E-Mail (eine pro Zeile)",
86 | "previewCSV": "CSV-Inhalt anzeigen"
87 | },
88 | "uploader": {
89 | "button": "CSV-Datei hochladen",
90 | "format": "Format: Name;E-Mail (eine pro Zeile)",
91 | "preview": "CSV-Inhalt anzeigen",
92 | "messages": {
93 | "success": "CSV-Datei erfolgreich geladen",
94 | "error": "Fehler beim Lesen der CSV-Datei",
95 | "importSuccess": "Kontakte erfolgreich importiert"
96 | }
97 | }
98 | },
99 | "campaigns": {
100 | "title": "Kampagnen",
101 | "newCampaign": {
102 | "title": "Neue Kampagne erstellen",
103 | "name": "Kampagnenname",
104 | "subject": "Betreff",
105 | "template": "Vorlage",
106 | "contactList": "Kontaktliste",
107 | "selectTemplate": "Vorlage auswählen",
108 | "selectContactList": "Kontaktliste auswählen",
109 | "contactCount": "Kontakte",
110 | "createButton": "Kampagne erstellen",
111 | "totalContacts": "Gesamtzahl der ausgewählten Kontakte",
112 | "listsSelected": "Ausgewählte Kontaktliste(n)"
113 | },
114 | "table": {
115 | "campaign": "Kampagne",
116 | "date": "Datum",
117 | "template": "Vorlage",
118 | "contactList": "Kontaktliste",
119 | "status": "Status",
120 | "progress": "Fortschritt",
121 | "actions": "Aktionen"
122 | },
123 | "status": {
124 | "draft": "Entwurf",
125 | "sending": "Wird gesendet",
126 | "completed": "Abgeschlossen",
127 | "completedwitherrors": "Abgeschlossen mit Fehlern",
128 | "failed": "Fehlgeschlagen"
129 | },
130 | "actions": {
131 | "start": "Kampagne starten",
132 | "pause": "Pausieren",
133 | "retry": "Erneut versuchen",
134 | "delete": "Kampagne löschen",
135 | "viewErrors": "Fehler anzeigen"
136 | },
137 | "messages": {
138 | "fillFields": "Bitte füllen Sie alle Felder aus",
139 | "notFound": "Ausgewählte Vorlage oder Kontaktliste nicht gefunden",
140 | "createSuccess": "Kampagne erfolgreich erstellt",
141 | "deleteSuccess": "Kampagne erfolgreich gelöscht",
142 | "pauseSuccess": "Kampagne pausiert",
143 | "cantDelete": "Kampagne kann während des Sendens nicht gelöscht werden",
144 | "configureSmtp": "Bitte konfigurieren Sie zuerst die SMTP-Einstellungen",
145 | "emptyTemplate": "Die ausgewählte Vorlage hat keinen Inhalt. Bitte bearbeiten Sie zuerst die Vorlage.",
146 | "smtpAuth": "Bitte geben Sie die SMTP-Authentifizierungsdetails an",
147 | "noContacts": "Keine Kontakte zum Senden verfügbar",
148 | "completedWithErrors": "Kampagne mit {0} Fehlern abgeschlossen. {1} E-Mails erfolgreich gesendet.",
149 | "completedSuccess": "Kampagne abgeschlossen: {0} E-Mails erfolgreich gesendet"
150 | },
151 | "errors": {
152 | "title": "Kampagnenfehler",
153 | "close": "Schließen",
154 | "emailColumn": "E-Mail",
155 | "errorColumn": "Fehler"
156 | }
157 | },
158 | "settings": {
159 | "title": "Einstellungen",
160 | "smtp": {
161 | "title": "SMTP-Einstellungen",
162 | "host": "SMTP-Host",
163 | "port": "Port",
164 | "useSSL": "SSL verwenden",
165 | "sslPortMessage": "SSL-Port (465) wird verwendet",
166 | "tlsPortMessage": "TLS/STARTTLS-Port (587) wird verwendet",
167 | "username": "Benutzername",
168 | "password": "Passwort",
169 | "fromEmail": "Absender-E-Mail",
170 | "fromName": "Absender-Name",
171 | "saveButton": "Einstellungen speichern",
172 | "savingButton": "Wird gespeichert...",
173 | "smtpAuth": "SMTP-Authentifizierung",
174 | "smtpAuthWarning": "Aktivieren, wenn Ihr SMTP-Server eine Authentifizierung erfordert"
175 | },
176 | "toast": {
177 | "success": "Einstellungen erfolgreich gespeichert!",
178 | "error": "Fehler beim Speichern der Einstellungen"
179 | }
180 | }
181 | }
182 |
--------------------------------------------------------------------------------
/src/locales/en.json:
--------------------------------------------------------------------------------
1 | {
2 | "templates": {
3 | "title": "Templates",
4 | "emailTemplates": "Email Templates",
5 | "uploadTemplate": "Upload Template",
6 | "newTemplate": "New Template",
7 | "selectOrCreate": "Select a template or create a new one",
8 | "messages": {
9 | "uploadSuccess": "Template uploaded successfully",
10 | "createSuccess": "New template created",
11 | "failedSave": "Failed to save template, please check the logs"
12 | },
13 | "editor": {
14 | "templateName": "Template Name",
15 | "templateNamePlaceholder": "Enter template name",
16 | "visualMode": "Visual Mode",
17 | "codeMode": "Code Mode",
18 | "preview": "Preview",
19 | "richText": {
20 | "bold": "Bold",
21 | "italic": "Italic",
22 | "bulletList": "Bullet List",
23 | "orderedList": "Numbered List",
24 | "link": "Insert Link",
25 | "heading": "Heading",
26 | "enterUrl": "Enter URL"
27 | }
28 | },
29 | "list": {
30 | "deleteSuccess": "Template deleted successfully",
31 | "deleteConfirm": "Are you sure you want to delete this template?"
32 | }
33 | },
34 | "contacts": {
35 | "title": "Contacts",
36 | "list": {
37 | "title": "Contact Lists",
38 | "newList": "New List",
39 | "contactCount": "{0} contacts",
40 | "columns": {
41 | "name": "Name",
42 | "email": "Email",
43 | "actions": "Actions"
44 | },
45 | "actions": {
46 | "edit": "Edit",
47 | "delete": "Delete",
48 | "save": "Save",
49 | "cancel": "Cancel",
50 | "addContact": "Add Contact",
51 | "deleteList": "Delete List",
52 | "exportCSV": "Export CSV"
53 | },
54 | "form": {
55 | "listName": "List Name",
56 | "listNamePlaceholder": "My Contact List",
57 | "saveList": "Save List"
58 | },
59 | "messages": {
60 | "selectContactList": "Select a contact list or create a new one",
61 | "deleteSuccess": "Contact list deleted successfully",
62 | "updateSuccess": "Contact updated successfully",
63 | "deleteContactSuccess": "Contact deleted successfully",
64 | "duplicateEmail": "Email address already exists",
65 | "duplicatesRemoved": "Duplicate or existing email addresses were removed",
66 | "nameRequired": "Please enter a list name",
67 | "dataRequired": "Please enter contact data or upload a CSV file",
68 | "listCreated": "Contact list created successfully",
69 | "contactAdded": "Contact added successfully",
70 | "exportSuccess": "Contact list exported successfully"
71 | }
72 | },
73 | "addContact": {
74 | "title": "Add Contact",
75 | "name": "Name",
76 | "namePlaceholder": "John Doe",
77 | "email": "Email",
78 | "emailPlaceholder": "john@example.com",
79 | "importTitle": "Or import multiple contacts",
80 | "validation": {
81 | "nameEmail": "Name and email are required",
82 | "validEmail": "Please enter a valid email address"
83 | },
84 | "uploadCSV": "Upload CSV File",
85 | "csvFormat": "Format: name;email (one per line)",
86 | "previewCSV": "Preview CSV Content"
87 | },
88 | "uploader": {
89 | "button": "Upload CSV File",
90 | "format": "Format: name;email (one per line)",
91 | "preview": "Preview CSV Content",
92 | "messages": {
93 | "success": "CSV file loaded successfully",
94 | "error": "Error reading CSV file",
95 | "importSuccess": "Contacts imported successfully"
96 | }
97 | }
98 | },
99 | "campaigns": {
100 | "title": "Campaigns",
101 | "newCampaign": {
102 | "title": "Create New Campaign",
103 | "name": "Campaign Name",
104 | "subject": "Subject",
105 | "template": "Template",
106 | "contactList": "Contact List",
107 | "selectTemplate": "Select a template",
108 | "selectContactList": "Select a contact list",
109 | "contactCount": "contacts",
110 | "createButton": "Create Campaign",
111 | "totalContacts": "Total selected contacts",
112 | "listsSelected": "selected contact list(s)"
113 | },
114 | "table": {
115 | "campaign": "Campaign",
116 | "date": "Date",
117 | "template": "Template",
118 | "contactList": "Contact List",
119 | "status": "Status",
120 | "progress": "Progress",
121 | "actions": "Actions"
122 | },
123 | "status": {
124 | "draft": "Draft",
125 | "sending": "Sending",
126 | "completed": "Completed",
127 | "completedwitherrors": "Completed with errors",
128 | "failed": "Failed"
129 | },
130 | "actions": {
131 | "start": "Start Campaign",
132 | "pause": "Pause Campaign",
133 | "retry": "Retry Campaign",
134 | "delete": "Delete Campaign",
135 | "viewErrors": "View Errors"
136 | },
137 | "messages": {
138 | "fillFields": "Please fill in all fields",
139 | "notFound": "Selected template or contact list not found",
140 | "emptyTemplate": "The selected template has no content. Please edit the template first.",
141 | "createSuccess": "Campaign created successfully",
142 | "deleteSuccess": "Campaign deleted successfully",
143 | "pauseSuccess": "Campaign paused",
144 | "cantDelete": "Cannot delete a campaign while it is sending",
145 | "configureSmtp": "Please configure SMTP settings first",
146 | "smtpAuth": "Please provide SMTP authentication details",
147 | "noContacts": "No contacts available to send to",
148 | "completedWithErrors": "Campaign completed with {0} errors. {1} emails sent successfully.",
149 | "completedSuccess": "Campaign completed: {0} emails sent successfully"
150 | },
151 | "errors": {
152 | "title": "Campaign Errors",
153 | "close": "Close",
154 | "emailColumn": "Email",
155 | "errorColumn": "Error"
156 | }
157 | },
158 | "settings": {
159 | "title": "Settings",
160 | "smtp": {
161 | "title": "SMTP Settings",
162 | "host": "SMTP Host",
163 | "port": "Port",
164 | "useSSL": "Use SSL",
165 | "sslPortMessage": "Using SSL port (465)",
166 | "tlsPortMessage": "Using TLS/STARTTLS port (587)",
167 | "username": "Username",
168 | "password": "Password",
169 | "fromEmail": "From Email",
170 | "fromName": "From Name",
171 | "saveButton": "Save Settings",
172 | "savingButton": "Saving...",
173 | "smtpAuth": "SMTP Authentication",
174 | "smtpAuthWarning" : "Enable if your SMTP server requires authentication"
175 | },
176 | "toast": {
177 | "success": "Settings saved successfully!",
178 | "error": "Failed to save settings"
179 | }
180 | }
181 | }
--------------------------------------------------------------------------------
/src/locales/es.json:
--------------------------------------------------------------------------------
1 | {
2 | "templates": {
3 | "title": "Plantillas",
4 | "emailTemplates": "Plantillas de Correo",
5 | "uploadTemplate": "Subir Plantilla",
6 | "newTemplate": "Nueva Plantilla",
7 | "selectOrCreate": "Seleccione una plantilla o cree una nueva",
8 | "messages": {
9 | "uploadSuccess": "Plantilla subida exitosamente",
10 | "createSuccess": "Nueva plantilla creada",
11 | "failedSave": "Error al guardar la plantilla, por favor revise los registros"
12 | },
13 | "editor": {
14 | "templateName": "Nombre de la Plantilla",
15 | "templateNamePlaceholder": "Ingrese nombre de la plantilla",
16 | "visualMode": "Modo Visual",
17 | "codeMode": "Modo Código",
18 | "preview": "Vista Previa",
19 | "richText": {
20 | "bold": "Negrita",
21 | "italic": "Cursiva",
22 | "bulletList": "Lista con Viñetas",
23 | "orderedList": "Lista Numerada",
24 | "link": "Insertar Enlace",
25 | "heading": "Encabezado",
26 | "enterUrl": "Ingrese la URL"
27 | }
28 | },
29 | "list": {
30 | "deleteSuccess": "Plantilla eliminada exitosamente",
31 | "deleteConfirm": "¿Está seguro de que desea eliminar esta plantilla?"
32 | }
33 | },
34 | "contacts": {
35 | "title": "Contactos",
36 | "list": {
37 | "title": "Listas de Contactos",
38 | "newList": "Nueva Lista",
39 | "contactCount": "{0} contactos",
40 | "columns": {
41 | "name": "Nombre",
42 | "email": "Correo",
43 | "actions": "Acciones"
44 | },
45 | "actions": {
46 | "edit": "Editar",
47 | "delete": "Eliminar",
48 | "save": "Guardar",
49 | "cancel": "Cancelar",
50 | "addContact": "Agregar Contacto",
51 | "deleteList": "Eliminar Lista",
52 | "exportCSV": "Exportar CSV"
53 | },
54 | "form": {
55 | "listName": "Nombre de la Lista",
56 | "listNamePlaceholder": "Mi Lista de Contactos",
57 | "saveList": "Guardar Lista"
58 | },
59 | "messages": {
60 | "selectContactList": "Seleccione una lista de contactos o cree una nueva",
61 | "deleteSuccess": "Lista de contactos eliminada exitosamente",
62 | "updateSuccess": "Contacto actualizado exitosamente",
63 | "deleteContactSuccess": "Contacto eliminado exitosamente",
64 | "duplicateEmail": "El correo electrónico ya existe",
65 | "duplicatesRemoved": "Se eliminaron las direcciones de correo duplicadas o existentes",
66 | "nameRequired": "Por favor ingrese un nombre para la lista",
67 | "dataRequired": "Por favor ingrese datos de contacto o suba un archivo CSV",
68 | "listCreated": "Lista de contactos creada exitosamente",
69 | "contactAdded": "Contacto agregado exitosamente",
70 | "exportSuccess": "Lista de contactos exportada exitosamente"
71 | }
72 | },
73 | "addContact": {
74 | "title": "Agregar Contacto",
75 | "name": "Nombre",
76 | "namePlaceholder": "Juan Pérez",
77 | "email": "Correo",
78 | "emailPlaceholder": "juan@ejemplo.com",
79 | "importTitle": "O importar múltiples contactos",
80 | "validation": {
81 | "nameEmail": "El nombre y correo son obligatorios",
82 | "validEmail": "Por favor ingrese un correo válido"
83 | },
84 | "uploadCSV": "Subir Archivo CSV",
85 | "csvFormat": "Formato: nombre;correo (uno por línea)",
86 | "previewCSV": "Vista previa del contenido CSV"
87 | },
88 | "uploader": {
89 | "button": "Subir Archivo CSV",
90 | "format": "Formato: nombre;correo (uno por línea)",
91 | "preview": "Vista previa del contenido CSV",
92 | "messages": {
93 | "success": "Archivo CSV cargado exitosamente",
94 | "error": "Error al leer el archivo CSV",
95 | "importSuccess": "Contactos importados exitosamente"
96 | }
97 | }
98 | },
99 | "campaigns": {
100 | "title": "Campañas",
101 | "newCampaign": {
102 | "title": "Crear Nueva Campaña",
103 | "name": "Nombre de la Campaña",
104 | "subject": "Asunto",
105 | "template": "Plantilla",
106 | "contactList": "Lista de Contactos",
107 | "selectTemplate": "Seleccionar una plantilla",
108 | "selectContactList": "Seleccionar una lista de contactos",
109 | "contactCount": "contactos",
110 | "createButton": "Crear Campaña",
111 | "totalContacts": "Total de contactos seleccionados",
112 | "listsSelected": "lista(s) de contactos seleccionada(s)"
113 | },
114 | "table": {
115 | "campaign": "Campaña",
116 | "date": "Fecha",
117 | "template": "Plantilla",
118 | "contactList": "Lista de Contactos",
119 | "status": "Estado",
120 | "progress": "Progreso",
121 | "actions": "Acciones"
122 | },
123 | "status": {
124 | "draft": "Borrador",
125 | "sending": "Enviando",
126 | "completed": "Completada",
127 | "completedwitherrors": "Completada con errores",
128 | "failed": "Fallida"
129 | },
130 | "actions": {
131 | "start": "Iniciar Campaña",
132 | "pause": "Pausar Campaña",
133 | "retry": "Reintentar Campaña",
134 | "delete": "Eliminar Campaña",
135 | "viewErrors": "Ver Errores"
136 | },
137 | "errors": {
138 | "title": "Errores de Campaña",
139 | "close": "Cerrar",
140 | "emailColumn": "Correo",
141 | "errorColumn": "Error"
142 | },
143 | "messages": {
144 | "fillFields": "Por favor complete todos los campos",
145 | "notFound": "Plantilla o lista de contactos no encontrada",
146 | "createSuccess": "Campaña creada exitosamente",
147 | "deleteSuccess": "Campaña eliminada exitosamente",
148 | "pauseSuccess": "Campaña pausada",
149 | "cantDelete": "No se puede eliminar una campaña mientras se está enviando",
150 | "configureSmtp": "Por favor configure los ajustes SMTP primero",
151 | "emptyTemplate": "La plantilla seleccionada no tiene contenido. Por favor edite la plantilla primero.",
152 | "smtpAuth": "Por favor configure los detalles de autenticación SMTP",
153 | "noContacts": "No hay contactos disponibles para enviar",
154 | "completedWithErrors": "Campaña completada con {0} errores. {1} emails enviados exitosamente.",
155 | "completedSuccess": "Campaña completada: {0} emails enviados exitosamente"
156 | }
157 | },
158 | "settings": {
159 | "title": "Ajustes",
160 | "smtp": {
161 | "title": "Configuración SMTP",
162 | "host": "Servidor SMTP",
163 | "port": "Puerto",
164 | "useSSL": "Usar SSL",
165 | "sslPortMessage": "Usando puerto SSL (465)",
166 | "tlsPortMessage": "Usando puerto TLS/STARTTLS (587)",
167 | "username": "Usuario",
168 | "password": "Contraseña",
169 | "fromEmail": "Correo remitente",
170 | "fromName": "Nombre remitente",
171 | "saveButton": "Guardar configuración",
172 | "savingButton": "Guardando...",
173 | "smtpAuth": "Autenticación SMTP",
174 | "smtpAuthWarning": "Habilitar si su servidor SMTP requiere autenticación"
175 | },
176 | "toast": {
177 | "success": "¡Configuración guardada correctamente!",
178 | "error": "Error al guardar la configuración"
179 | }
180 | }
181 | }
182 |
--------------------------------------------------------------------------------
/src/locales/fr.json:
--------------------------------------------------------------------------------
1 | {
2 | "templates": {
3 | "title": "Modèles",
4 | "emailTemplates": "Modèles d'e-mail",
5 | "uploadTemplate": "Télécharger un modèle",
6 | "newTemplate": "Nouveau modèle",
7 | "selectOrCreate": "Sélectionnez un modèle ou créez-en un nouveau",
8 | "messages": {
9 | "uploadSuccess": "Modèle téléchargé avec succès",
10 | "createSuccess": "Nouveau modèle créé",
11 | "failedSave": "Échec de l'enregistrement du modèle, veuillez vérifier les journaux"
12 | },
13 | "editor": {
14 | "templateName": "Nom du modèle",
15 | "templateNamePlaceholder": "Entrez le nom du modèle",
16 | "visualMode": "Mode visuel",
17 | "codeMode": "Mode code",
18 | "preview": "Aperçu",
19 | "richText": {
20 | "bold": "Gras",
21 | "italic": "Italique",
22 | "bulletList": "Liste à puces",
23 | "orderedList": "Liste numérotée",
24 | "link": "Insérer un lien",
25 | "heading": "Titre",
26 | "enterUrl": "Entrez l'URL"
27 | }
28 | },
29 | "list": {
30 | "deleteSuccess": "Modèle supprimé avec succès",
31 | "deleteConfirm": "Êtes-vous sûr de vouloir supprimer ce modèle ?"
32 | }
33 | },
34 | "contacts": {
35 | "title": "Contacts",
36 | "list": {
37 | "title": "Listes de contacts",
38 | "newList": "Nouvelle liste",
39 | "contactCount": "{0} contacts",
40 | "columns": {
41 | "name": "Nom",
42 | "email": "E-mail",
43 | "actions": "Actions"
44 | },
45 | "actions": {
46 | "edit": "Modifier",
47 | "delete": "Supprimer",
48 | "save": "Enregistrer",
49 | "cancel": "Annuler",
50 | "addContact": "Ajouter un contact",
51 | "deleteList": "Supprimer la liste",
52 | "exportCSV": "Exporter CSV"
53 | },
54 | "form": {
55 | "listName": "Nom de la liste",
56 | "listNamePlaceholder": "Ma liste de contacts",
57 | "saveList": "Enregistrer la liste"
58 | },
59 | "messages": {
60 | "selectContactList": "Sélectionnez une liste de contacts ou créez-en une nouvelle",
61 | "deleteSuccess": "Liste de contacts supprimée avec succès",
62 | "updateSuccess": "Contact mis à jour avec succès",
63 | "deleteContactSuccess": "Contact supprimé avec succès",
64 | "duplicateEmail": "L'adresse e-mail existe déjà",
65 | "duplicatesRemoved": "Les e-mails en double ou existants ont été supprimés",
66 | "nameRequired": "Veuillez entrer un nom de liste",
67 | "dataRequired": "Veuillez entrer des données de contact ou télécharger un fichier CSV",
68 | "listCreated": "Liste de contacts créée avec succès",
69 | "contactAdded": "Contact ajouté avec succès",
70 | "exportSuccess": "Liste de contacts exportée avec succès"
71 | }
72 | },
73 | "addContact": {
74 | "title": "Ajouter un contact",
75 | "name": "Nom",
76 | "namePlaceholder": "Jean Dupont",
77 | "email": "E-mail",
78 | "emailPlaceholder": "jean@exemple.com",
79 | "importTitle": "Ou importer plusieurs contacts",
80 | "validation": {
81 | "nameEmail": "Le nom et l'e-mail sont obligatoires",
82 | "validEmail": "Veuillez entrer une adresse e-mail valide"
83 | },
84 | "uploadCSV": "Télécharger un fichier CSV",
85 | "csvFormat": "Format : nom;e-mail (un par ligne)",
86 | "previewCSV": "Aperçu du contenu CSV"
87 | },
88 | "uploader": {
89 | "button": "Télécharger un fichier CSV",
90 | "format": "Format : nom;e-mail (un par ligne)",
91 | "preview": "Aperçu du contenu CSV",
92 | "messages": {
93 | "success": "Fichier CSV chargé avec succès",
94 | "error": "Erreur lors de la lecture du fichier CSV",
95 | "importSuccess": "Contacts importés avec succès"
96 | }
97 | }
98 | },
99 | "campaigns": {
100 | "title": "Campagnes",
101 | "newCampaign": {
102 | "title": "Créer une nouvelle campagne",
103 | "name": "Nom de la campagne",
104 | "subject": "Sujet",
105 | "template": "Modèle",
106 | "contactList": "Liste de contacts",
107 | "selectTemplate": "Sélectionner un modèle",
108 | "selectContactList": "Sélectionner une liste de contacts",
109 | "contactCount": "contacts",
110 | "createButton": "Créer la campagne",
111 | "totalContacts": "Total des contacts sélectionnés",
112 | "listsSelected": "liste(s) de contacts sélectionnée(s)"
113 | },
114 | "table": {
115 | "campaign": "Campagne",
116 | "date": "Date",
117 | "template": "Modèle",
118 | "contactList": "Liste de contacts",
119 | "status": "Statut",
120 | "progress": "Progression",
121 | "actions": "Actions"
122 | },
123 | "status": {
124 | "draft": "Brouillon",
125 | "sending": "Envoi en cours",
126 | "completed": "Terminée",
127 | "completedwitherrors": "Terminée avec des erreurs",
128 | "failed": "Échec"
129 | },
130 | "actions": {
131 | "start": "Démarrer la campagne",
132 | "pause": "Mettre en pause",
133 | "retry": "Réessayer",
134 | "delete": "Supprimer la campagne",
135 | "viewErrors": "Voir les erreurs"
136 | },
137 | "messages": {
138 | "fillFields": "Veuillez remplir tous les champs",
139 | "notFound": "Le modèle ou la liste de contacts sélectionné(e) est introuvable",
140 | "createSuccess": "Campagne créée avec succès",
141 | "deleteSuccess": "Campagne supprimée avec succès",
142 | "pauseSuccess": "Campagne mise en pause",
143 | "cantDelete": "Impossible de supprimer une campagne en cours d'envoi",
144 | "configureSmtp": "Veuillez configurer les paramètres SMTP d'abord",
145 | "emptyTemplate": "Le modèle sélectionné n'a pas de contenu. Veuillez d'abord modifier le modèle.",
146 | "smtpAuth": "Veuillez fournir les détails d’authentification SMTP",
147 | "noContacts": "Aucun contact disponible pour l'envoi",
148 | "completedWithErrors": "Campagne terminée avec {0} erreurs. {1} e-mails envoyés avec succès.",
149 | "completedSuccess": "Campagne terminée : {0} e-mails envoyés avec succès"
150 | },
151 | "errors": {
152 | "title": "Erreurs de campagne",
153 | "close": "Fermer",
154 | "emailColumn": "E-mail",
155 | "errorColumn": "Erreur"
156 | }
157 | },
158 | "settings": {
159 | "title": "Paramètres",
160 | "smtp": {
161 | "title": "Paramètres SMTP",
162 | "host": "Hôte SMTP",
163 | "port": "Port",
164 | "useSSL": "Utiliser SSL",
165 | "sslPortMessage": "Utilisation du port SSL (465)",
166 | "tlsPortMessage": "Utilisation du port TLS/STARTTLS (587)",
167 | "username": "Nom d'utilisateur",
168 | "password": "Mot de passe",
169 | "fromEmail": "E-mail de l'expéditeur",
170 | "fromName": "Nom de l'expéditeur",
171 | "saveButton": "Enregistrer les paramètres",
172 | "savingButton": "Enregistrement en cours...",
173 | "smtpAuth": "Authentification SMTP",
174 | "smtpAuthWarning": "Activer si votre serveur SMTP nécessite une authentification"
175 | },
176 | "toast": {
177 | "success": "Paramètres enregistrés avec succès !",
178 | "error": "Échec de l'enregistrement des paramètres"
179 | }
180 | }
181 | }
182 |
--------------------------------------------------------------------------------
/src/main.tsx:
--------------------------------------------------------------------------------
1 | import { StrictMode } from 'react';
2 | import { createRoot } from 'react-dom/client';
3 | import App from './App.tsx';
4 | import './index.css';
5 | import './i18n';
6 |
7 | createRoot(document.getElementById('root')!).render(
8 |
9 |
10 |
11 | );
12 |
--------------------------------------------------------------------------------
/src/store/useStore.ts:
--------------------------------------------------------------------------------
1 | import { create } from 'zustand';
2 | import { persist } from 'zustand/middleware';
3 | import { Template, Campaign, ContactList, SmtpConfig } from '../types';
4 | import axios from 'axios';
5 |
6 | const api = axios.create({
7 | baseURL: '/api',
8 | });
9 |
10 | type ActionType =
11 | | 'ADD_TEMPLATE'
12 | | 'UPDATE_TEMPLATE'
13 | | 'DELETE_TEMPLATE'
14 | | 'ADD_CONTACT_LIST'
15 | | 'UPDATE_CONTACT_LIST'
16 | | 'DELETE_CONTACT_LIST'
17 | | 'ADD_CAMPAIGN'
18 | | 'UPDATE_CAMPAIGN'
19 | | 'DELETE_CAMPAIGN';
20 |
21 | interface SaveSettingsAction {
22 | type: ActionType;
23 | data: any; // This could be Template, ContactList, Campaign, etc.
24 | }
25 |
26 | interface Store {
27 | isLoading: boolean;
28 | error: string | null;
29 | templates: Template[];
30 | contactLists: ContactList[];
31 | campaigns: Campaign[];
32 | smtpConfig: SmtpConfig;
33 | // Action types for saveSettings
34 |
35 |
36 | addTemplate: (template: Template) => Promise;
37 | updateTemplate: (id: string, template: Partial) => Promise;
38 | deleteTemplate: (id: string) => Promise;
39 | addContactList: (contactList: ContactList) => Promise;
40 | updateContactList: (id: string, contactList: Partial) => Promise;
41 | deleteContactList: (id: string) => Promise;
42 | createCampaign: (campaign: Campaign) => Promise;
43 | updateCampaign: (id: string, campaign: Partial) => Promise;
44 | deleteCampaign: (id: string) => Promise;
45 | updateSmtpConfig: (config: SmtpConfig) => Promise;
46 | fetchSettings: () => Promise;
47 | saveSettings: (action?: SaveSettingsAction) => Promise;
48 | setError: (error: string | null) => void;
49 | setLoading: (loading: boolean) => void;
50 | }
51 |
52 | export const useStore = create()(
53 | persist(
54 | (set, get) => ({
55 | templates: [],
56 | contactLists: [],
57 | campaigns: [],
58 | smtpConfig: {
59 | host: '',
60 | port: 587,
61 | username: '',
62 | password: '',
63 | fromEmail: '',
64 | fromName: '',
65 | useSSL: false,
66 | useAuth: false,
67 | },
68 | addTemplate: async (template) => {
69 | set((state) => ({ templates: [...state.templates, template] }));
70 | await get().saveSettings({
71 | type: 'ADD_TEMPLATE',
72 | data: template
73 | });
74 | },
75 | updateTemplate: async (id, template) => {
76 | try {
77 | set((state) => ({
78 | templates: state.templates.map((t) =>
79 | t.id === id ? { ...t, ...template } : t
80 | ),
81 | }));
82 | await get().saveSettings({
83 | type: 'UPDATE_TEMPLATE',
84 | data: { id, ...template }
85 | });
86 | } catch (error) {
87 | throw error; // Re-throw to be caught by the component
88 | }
89 | },
90 | deleteTemplate: async (id) => {
91 | set((state) => ({
92 | templates: state.templates.filter((t) => t.id !== id),
93 | }));
94 | await get().saveSettings({
95 | type: 'DELETE_TEMPLATE',
96 | data: { id }
97 | });
98 | },
99 | addContactList: async (contactList) => {
100 | set((state) => ({ contactLists: [...state.contactLists, contactList] }));
101 | await get().saveSettings({
102 | type: 'ADD_CONTACT_LIST',
103 | data: contactList
104 | });
105 | },
106 | updateContactList: async (id, contactList) => {
107 | set((state) => ({
108 | contactLists: state.contactLists.map((cl) =>
109 | cl.id === id ? { ...cl, ...contactList } : cl
110 | ),
111 | }));
112 | await get().saveSettings({
113 | type: 'UPDATE_CONTACT_LIST',
114 | data: { id, ...contactList }
115 | });
116 | },
117 | deleteContactList: async (id) => {
118 | set((state) => ({
119 | contactLists: state.contactLists.filter((cl) => cl.id !== id),
120 | }));
121 | await get().saveSettings({
122 | type: 'DELETE_CONTACT_LIST',
123 | data: { id }
124 | });
125 | },
126 | createCampaign: async (campaign) => {
127 | set((state) => ({ campaigns: [...state.campaigns, campaign] }));
128 | await get().saveSettings({
129 | type: 'ADD_CAMPAIGN',
130 | data: campaign
131 | });
132 | },
133 | updateCampaign: async (id, campaign) => {
134 | set((state) => ({
135 | campaigns: state.campaigns.map((c) =>
136 | c.id === id ? { ...c, ...campaign } : c
137 | ),
138 | }));
139 | await get().saveSettings({
140 | type: 'UPDATE_CAMPAIGN',
141 | data: { id, ...campaign }
142 | });
143 | },
144 | deleteCampaign: async (id) => {
145 | set((state) => ({
146 | campaigns: state.campaigns.filter((c) => c.id !== id)
147 | }));
148 | await get().saveSettings({
149 | type: 'DELETE_CAMPAIGN',
150 | data: { id }
151 | });
152 | },
153 | updateSmtpConfig: async (config) => {
154 | set((state) => ({ smtpConfig: { ...state.smtpConfig, ...config } }));
155 | await get().saveSettings();
156 | },
157 | fetchSettings: async () => {
158 | try {
159 | set({ isLoading: true, error: null });
160 | const response = await api.get('/settings');
161 | set(response.data);
162 | } catch (error) {
163 | set({ error: 'Failed to fetch settings' });
164 | console.error('Failed to fetch settings:', error);
165 | } finally {
166 | set({ isLoading: false });
167 | }
168 | },
169 | saveSettings: async (action?: SaveSettingsAction) => {
170 | try {
171 | set({ isLoading: true, error: null });
172 | const state = get();
173 | await api.post('/settings', {
174 | smtpConfig: state.smtpConfig,
175 | action,
176 | data: action?.data
177 | });
178 | } catch (error) {
179 | set({ error: error instanceof Error ? error.message : 'Failed to save settings' });
180 | console.error('Failed to save settings:', error);
181 | throw error; // Make sure to re-throw for the component to catch
182 | } finally {
183 | set({ isLoading: false });
184 | }
185 | },
186 | isLoading: false,
187 | error: null,
188 | setError: (error) => set({ error }),
189 | setLoading: (loading) => set({ isLoading: loading }),
190 | }),
191 | {
192 | name: 'email-campaign-storage',
193 | // Add onRehydrateStorage to handle post-rehydration tasks
194 | onRehydrateStorage: () => (state) => {
195 | // Optionally fetch fresh data after rehydration
196 | if (state) {
197 | state.fetchSettings();
198 | }
199 | }
200 | }
201 | )
202 | );
--------------------------------------------------------------------------------
/src/types/index.ts:
--------------------------------------------------------------------------------
1 | export interface Template {
2 | id: string;
3 | name: string;
4 | content: string;
5 | }
6 |
7 | export interface EmailContact {
8 | id: string;
9 | name: string;
10 | email: string;
11 | }
12 |
13 | export interface ContactList {
14 | id: string;
15 | name: string;
16 | contacts: EmailContact[];
17 | }
18 |
19 | export interface Campaign {
20 | id: string;
21 | name: string;
22 | subject: string;
23 | templateId: string;
24 | templateName: string;
25 | contactListIds: string[];
26 | contactListNames: string[];
27 | status: 'draft' | 'sending' | 'completed' | 'failed' | 'completed with errors';
28 | sentCount: number;
29 | totalCount: number;
30 | createDate?: string;
31 | error?: string;
32 | }
33 |
34 | export interface SmtpConfig {
35 | host: string;
36 | port: number;
37 | username: string;
38 | password: string;
39 | fromName: string;
40 | fromEmail: string;
41 | useSSL: boolean;
42 | useAuth: boolean;
43 | }
44 |
45 | export interface SendEmailResponse {
46 | success: boolean;
47 | error?: string;
48 | }
--------------------------------------------------------------------------------
/src/utils/csvParser.ts:
--------------------------------------------------------------------------------
1 | import { EmailContact } from '../types';
2 |
3 | export const parseCSV = (content: string): EmailContact[] => {
4 | try {
5 | const existingEmails = new Set();
6 | return content
7 | .split('\n')
8 | .filter(line => line.trim())
9 | .reduce((acc, line) => {
10 | const [name, email] = line.split(';').map(field => field.trim());
11 |
12 | if (!name || !email) {
13 | throw new Error('Invalid CSV format: Each line must contain a name and email separated by semicolon');
14 | }
15 |
16 | if (!email.includes('@')) {
17 | throw new Error(`Invalid email format: ${email}`);
18 | }
19 |
20 | if (existingEmails.has(email)) {
21 | return acc; // Skip duplicate emails
22 | }
23 |
24 | existingEmails.add(email);
25 | acc.push({
26 | id: Date.now().toString() + Math.random().toString(36).substr(2, 9),
27 | name,
28 | email
29 | });
30 |
31 | return acc;
32 | }, []);
33 | } catch (error) {
34 | throw new Error(`Failed to parse CSV: ${error.message}`);
35 | }
36 | };
--------------------------------------------------------------------------------
/src/utils/emailService.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import { EmailContact, SmtpConfig, SendEmailResponse } from '../types';
3 |
4 | const api = axios.create({
5 | baseURL: '/api',
6 | });
7 |
8 | export const verifySmtpConnection = async (config: SmtpConfig): Promise => {
9 | try {
10 | const response = await api.post('/verify-smtp', config);
11 | return response.data;
12 | } catch (error: any) {
13 | return {
14 | success: false,
15 | error: error.response?.data?.error || error.message
16 | };
17 | }
18 | };
19 |
20 | export const sendEmail = async (
21 | contact: EmailContact,
22 | subject: string,
23 | content: string,
24 | smtpConfig: SmtpConfig
25 | ): Promise => {
26 | try {
27 | const response = await api.post('/send-email', {
28 | contact,
29 | subject,
30 | content,
31 | smtpConfig
32 | });
33 | return response.data;
34 | } catch (error: any) {
35 | return {
36 | success: false,
37 | error: error.response?.data?.error || error.message
38 | };
39 | }
40 | };
--------------------------------------------------------------------------------
/src/utils/languageUtils.ts:
--------------------------------------------------------------------------------
1 | const importLocales = import.meta.glob('../locales/*.json', { eager: true });
2 |
3 | export function getAvailableLanguages(): { [key: string]: string } {
4 | const languageCodes = Object.keys(importLocales).map(path => {
5 | const matches = path.match(/\/locales\/([^/]+)\.json$/);
6 | return matches ? matches[1] : null;
7 | }).filter(Boolean) as string[];
8 |
9 | const nativeNames = new Intl.DisplayNames(['en'], { type: 'language' });
10 |
11 | return languageCodes.reduce((acc, code) => ({
12 | ...acc,
13 | [code]: nativeNames.of(code) || code
14 | }), {});
15 | }
--------------------------------------------------------------------------------
/src/utils/smtp.ts:
--------------------------------------------------------------------------------
1 | import nodemailer from 'nodemailer';
2 | import { SmtpConfig } from '../types';
3 |
4 | export const createTransporter = (config: SmtpConfig) => {
5 | return nodemailer.createTransport({
6 | host: config.host,
7 | port: config.port,
8 | secure: config.port === 465,
9 | auth: {
10 | user: config.username,
11 | pass: config.password,
12 | },
13 | });
14 | };
15 |
16 | export const verifySmtpConnection = async (config: SmtpConfig): Promise => {
17 | try {
18 | const transporter = createTransporter(config);
19 | await transporter.verify();
20 | return true;
21 | } catch (error) {
22 | return false;
23 | }
24 | };
--------------------------------------------------------------------------------
/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | export default {
3 | content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
4 | theme: {
5 | extend: {},
6 | },
7 | plugins: [],
8 | };
9 |
--------------------------------------------------------------------------------
/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2020",
4 | "useDefineForClassFields": true,
5 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "bundler",
11 | "allowImportingTsExtensions": true,
12 | "isolatedModules": true,
13 | "moduleDetection": "force",
14 | "noEmit": true,
15 | "jsx": "react-jsx",
16 |
17 | /* Linting */
18 | "strict": true,
19 | "noUnusedLocals": true,
20 | "noUnusedParameters": true,
21 | "noFallthroughCasesInSwitch": true
22 | },
23 | "include": ["src"]
24 | }
25 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "files": [],
3 | "references": [
4 | { "path": "./tsconfig.app.json" },
5 | { "path": "./tsconfig.node.json" }
6 | ]
7 | }
8 |
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2022",
4 | "lib": ["ES2023"],
5 | "module": "ESNext",
6 | "skipLibCheck": true,
7 |
8 | /* Bundler mode */
9 | "moduleResolution": "bundler",
10 | "allowImportingTsExtensions": true,
11 | "isolatedModules": true,
12 | "moduleDetection": "force",
13 | "noEmit": true,
14 |
15 | /* Linting */
16 | "strict": true,
17 | "noUnusedLocals": true,
18 | "noUnusedParameters": true,
19 | "noFallthroughCasesInSwitch": true
20 | },
21 | "include": ["vite.config.ts"]
22 | }
23 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite';
2 | import react from '@vitejs/plugin-react';
3 |
4 | // https://vitejs.dev/config/
5 | export default defineConfig({
6 | plugins: [react()],
7 | optimizeDeps: {
8 | exclude: ['lucide-react'],
9 | },
10 | server: {
11 | host: '0.0.0.0'
12 | },
13 | });
14 |
--------------------------------------------------------------------------------