diff --git a/CyberCP/settings.py b/CyberCP/settings.py index 926960ea8..933314139 100644 --- a/CyberCP/settings.py +++ b/CyberCP/settings.py @@ -75,6 +75,8 @@ INSTALLED_APPS = [ 'CLManager', 'IncBackups', 'aiScanner', + 'webmail', + 'emailDelivery', # 'WebTerminal' ] diff --git a/CyberCP/urls.py b/CyberCP/urls.py index da7ab903a..51e1a13ad 100644 --- a/CyberCP/urls.py +++ b/CyberCP/urls.py @@ -45,5 +45,7 @@ urlpatterns = [ path('CloudLinux/', include('CLManager.urls')), path('IncrementalBackups/', include('IncBackups.urls')), path('aiscanner/', include('aiScanner.urls')), + path('webmail/', include('webmail.urls')), + path('emailDelivery/', include('emailDelivery.urls')), # path('Terminal/', include('WebTerminal.urls')), ] diff --git a/README.md b/README.md index ab5ee0242..66a26599e 100755 --- a/README.md +++ b/README.md @@ -181,6 +181,29 @@ sh <(curl https://raw.githubusercontent.com/usmannasir/cyberpanel/stable/preUpgr --- +## ๐Ÿงช Testing + +CyberPanel includes an OLS feature test suite with 128 tests covering all custom OpenLiteSpeed features. + +### Running Tests + +```bash +# On the target server, set up test data (once): +bash tests/ols_test_setup.sh + +# Run the full 128-test suite: +bash tests/ols_feature_tests.sh +``` + +### Test Coverage + +| Phase | Tests | Coverage | +|-------|-------|----------| +| Phase 1: Live Environment | 56 | Binary integrity, CyberPanel module, Auto-SSL, LE certificates, SSL listener auto-mapping, cert serving, HTTPS/HTTP, .htaccess processing, VHost config, origin headers, PHP config | +| Phase 2: ReadApacheConf | 72 | Include/IncludeOptional, global tuning, listener creation, ProxyPass, IfModule, VHost creation, SSL dedup, Directory/Location blocks, PHP version detection, ScriptAlias, HTTP/HTTPS, process health, graceful restart | + +--- + ## ๐Ÿ”ง Troubleshooting ### **Common Issues & Solutions** diff --git a/baseTemplate/templates/baseTemplate/index.html b/baseTemplate/templates/baseTemplate/index.html index 8908a0259..a7902ac55 100644 --- a/baseTemplate/templates/baseTemplate/index.html +++ b/baseTemplate/templates/baseTemplate/index.html @@ -1,6 +1,6 @@ {% load i18n %} {% get_current_language as LANGUAGE_CODE %} -{% with CP_VERSION="2.4.4.1" %} +{% with CP_VERSION="2.4.5" %} @@ -1599,7 +1599,7 @@ {% endif %} {% if admin or createEmail %} - + Access Webmail {% endif %} @@ -1937,8 +1937,12 @@ RSPAMD + + Email Delivery + NEW + - +
diff --git a/baseTemplate/views.py b/baseTemplate/views.py index eedf0d871..f3a228d06 100644 --- a/baseTemplate/views.py +++ b/baseTemplate/views.py @@ -29,7 +29,7 @@ import pwd # Create your views here. VERSION = '2.4' -BUILD = 4 +BUILD = 5 @ensure_csrf_cookie diff --git a/docs/CYBERMAIL_SETUP_GUIDE.md b/docs/CYBERMAIL_SETUP_GUIDE.md new file mode 100644 index 000000000..3cede4155 --- /dev/null +++ b/docs/CYBERMAIL_SETUP_GUIDE.md @@ -0,0 +1,266 @@ +# CyberMail Email Delivery โ€” Setup & Administration Guide + +**Feature**: CyberMail Email Delivery Integration +**CyberPanel Version**: 2.4.5+ +**Platform**: https://platform.cyberpersons.com +**Last Updated**: 2026-03-06 + +--- + +## Overview + +CyberMail Email Delivery is a built-in CyberPanel feature that routes outgoing emails through CyberMail's optimized delivery infrastructure. It solves common email deliverability problems โ€” emails landing in spam, IP blacklisting, missing DNS records โ€” by providing dedicated sending servers, automatic DNS configuration, and real-time delivery analytics. + +### Key Benefits + +- **15,000 emails/month free** on the Free plan +- **Automatic DNS setup** โ€” SPF, DKIM, and DMARC records configured in one click +- **SMTP relay** โ€” route all server email through CyberMail with one toggle +- **Real-time analytics** โ€” delivery logs, bounce tracking, reputation monitoring +- **Multi-region delivery** โ€” 4 delivery nodes with 99.9% uptime SLA +- **98%+ inbox rate** across major providers (Gmail, Outlook, Yahoo) + +--- + +## Prerequisites + +1. CyberPanel 2.4.5 or later installed +2. Active internet connection from the server +3. PowerDNS running (for automatic DNS configuration) +4. Postfix installed (for SMTP relay feature) + +--- + +## Installation + +The CyberMail module is included in CyberPanel 2.4.5+. No separate installation needed. + +### Verify the Module + +```bash +# Check the app exists +ls /usr/local/CyberCP/emailDelivery/ + +# Check it's in INSTALLED_APPS +grep -n "emailDelivery" /usr/local/CyberCP/CyberCP/settings.py + +# Run migrations if needed +cd /usr/local/CyberCP +python manage.py migrate emailDelivery +``` + +### Database Tables + +The module creates two tables: + +| Table | Purpose | +|-------|---------| +| `cybermail_accounts` | Stores per-admin CyberMail account connections | +| `cybermail_domains` | Tracks sending domains and their verification status | + +--- + +## Getting Started + +### Step 1: Access CyberMail + +Navigate to: **https://your-server:8090/emailDelivery/** + +You'll see the CyberMail marketing page with plan information and a "Get Started Free" button. + +### Step 2: Connect Your Account + +1. Click **"Get Started Free"** +2. Enter your email address (defaults to your CyberPanel admin email) +3. Create a password for your CyberMail account +4. Click **Connect** + +This registers your account on the CyberMail platform and obtains an API key that's stored locally for future API calls. + +> **Note**: If you already have a CyberMail account on the platform, use the same email and password. The system will link your existing account. + +### Step 3: Add Sending Domains + +1. After connecting, you'll see the dashboard +2. Go to the **Domains** tab +3. Click **"Add Domain"** +4. Enter your domain name (e.g., `example.com`) +5. Click **Add** + +The system will: +- Register the domain on the CyberMail platform +- Automatically create SPF, DKIM, and DMARC DNS records in PowerDNS +- Report how many DNS records were configured + +### Step 4: Verify Domain + +1. Click **"Verify"** next to your domain +2. The system checks SPF, DKIM, and DMARC records +3. Green checkmarks appear for each verified record +4. Status changes to "Verified" when all records pass + +> **DNS Propagation**: If verification fails immediately after adding, wait 5-10 minutes for DNS propagation and try again. + +### Step 5: Enable SMTP Relay (Optional) + +The SMTP relay routes ALL outgoing email from your server through CyberMail: + +1. Go to the **Relay** tab +2. Click **"Enable Relay"** +3. The system will: + - Create (or rotate) SMTP credentials on the platform + - Configure Postfix with the relay host (`mail.cyberpersons.com:587`) + - Set up SASL authentication + - Enable TLS encryption + +**What gets configured in Postfix** (`/etc/postfix/main.cf`): +``` +relayhost = [mail.cyberpersons.com]:587 +smtp_sasl_auth_enable = yes +smtp_sasl_password_maps = hash:/etc/postfix/sasl_passwd +smtp_sasl_security_options = noanonymous +smtp_tls_security_level = encrypt +``` + +--- + +## Dashboard Overview + +After connecting, the dashboard provides five tabs: + +### Domains Tab +- Lists all sending domains with verification status +- SPF, DKIM, DMARC status badges (green = verified) +- Actions: Verify, Auto-Configure DNS, Remove +- DNS auto-configuration works when the domain exists in PowerDNS + +### SMTP Tab +- Manage SMTP credentials for sending +- Create new credentials with descriptions +- Rotate passwords (one-time display) +- Delete unused credentials + +### Relay Tab +- Shows current relay status (Enabled/Disabled) +- Displays relay host and port +- Enable/Disable toggle +- Relay info: `mail.cyberpersons.com:587` with STARTTLS + +### Logs Tab +- Paginated delivery logs +- Filter by: Status (delivered/bounced/failed), Days (1-30) +- Shows: Date, From, To, Subject, Status +- Color-coded status badges + +### Stats Tab +- Aggregate stats: Total Sent, Delivered, Bounced, Failed, Delivery Rate +- Per-domain breakdown table +- Useful for identifying domains with deliverability issues + +--- + +## Plans & Pricing + +| Plan | Price | Emails/Month | Features | +|------|-------|-------------|----------| +| **Free** | $0 | 15,000 | Shared infrastructure, basic analytics | +| **Starter** | $15/mo | 100,000 | Priority support, advanced analytics | +| **Professional** | $90/mo | 500,000 | Dedicated IPs, custom DKIM, webhooks | +| **Enterprise** | $299/mo | 2,000,000 | SLA guarantee, account manager, custom limits | + +Upgrade at: https://platform.cyberpersons.com + +--- + +## Promotional Banners + +CyberMail banners appear on email-related pages to inform users about the delivery service: + +- Mail Functions (`/mailServer/`) +- Create Email Account +- DKIM Manager +- Webmail +- Email Premium +- Email Marketing + +Banners are dismissible with a 7-day cookie-based suppression. They show: +- "Stop Landing in Spam" headline +- Brief feature description +- "Get Started Free" CTA linking to `/emailDelivery/` + +--- + +## Disconnecting + +1. Go to **https://your-server:8090/emailDelivery/** +2. Click the **"Disconnect"** button +3. Confirm the action + +Disconnecting will: +- Disable SMTP relay if active (remove Postfix relay config) +- Clear the stored API key +- Remove local domain records +- Reset SMTP credential references + +> **Note**: Your CyberMail platform account is NOT deleted. You can reconnect later with the same credentials. + +--- + +## Troubleshooting + +### "Account not connected" error +The admin session doesn't have an active CyberMail connection. Click "Get Started Free" to connect. + +### DNS records not auto-configured +- The domain must exist in PowerDNS on this server +- Check if the domain was created via CyberPanel's DNS management +- If using external DNS, add records manually using the DNS records shown on the platform + +### Domain verification failing +- Wait 5-10 minutes after DNS changes for propagation +- Verify records exist: `dig TXT example.com +short` +- Check for conflicting SPF records (only one SPF record allowed per domain) + +### SMTP relay not working +- Check Postfix status: `systemctl status postfix` +- Verify relay config: `grep relayhost /etc/postfix/main.cf` +- Check SASL credentials: `cat /etc/postfix/sasl_passwd` +- Test connectivity: `telnet mail.cyberpersons.com 587` +- Check mail queue: `mailq` +- View Postfix logs: `tail -f /var/log/mail.log` + +### Relay shows "Failed to configure" +- Ensure `/usr/local/CyberCP/plogical/mailUtilities.py` has the `configureRelayHost` method +- Check file permissions on `/etc/postfix/sasl_passwd` +- Verify Postfix is installed and running + +### Emails still going to spam +1. Verify all DNS records (SPF, DKIM, DMARC) are green +2. Check your domain's reputation at https://www.mail-tester.com +3. Ensure you're not sending to purchased/scraped lists +4. Consider upgrading to a plan with dedicated IPs + +--- + +## File Reference + +| File | Purpose | +|------|---------| +| `emailDelivery/emailDeliveryManager.py` | Core business logic, platform API calls | +| `emailDelivery/models.py` | Database models (CyberMailAccount, CyberMailDomain) | +| `emailDelivery/views.py` | Django view functions (thin wrappers) | +| `emailDelivery/urls.py` | URL routing (18 endpoints) | +| `emailDelivery/static/emailDelivery/emailDelivery.js` | AngularJS controller | +| `emailDelivery/templates/emailDelivery/index.html` | Single-page template (marketing + dashboard) | +| `plogical/mailUtilities.py` | Postfix relay configuration (configureRelayHost/removeRelayHost) | + +--- + +## Security + +- API keys are stored per-admin in the local database, never in config files +- All platform API calls use HTTPS with Bearer token authentication +- SMTP credentials use SASL over TLS (STARTTLS on port 587) +- SASL password file is chmod 600 (root-only readable) +- Session-based authentication with CSRF protection on all endpoints +- Passwords are never stored locally โ€” only on the platform diff --git a/docs/CYBERMAIL_TECHNICAL_REFERENCE.md b/docs/CYBERMAIL_TECHNICAL_REFERENCE.md new file mode 100644 index 000000000..4055d1325 --- /dev/null +++ b/docs/CYBERMAIL_TECHNICAL_REFERENCE.md @@ -0,0 +1,393 @@ +# CyberMail Email Delivery โ€” Technical Reference + +**Module**: `emailDelivery` +**Platform API Base**: `https://platform.cyberpersons.com/email/cp/` +**Last Updated**: 2026-03-06 + +--- + +## Architecture + +``` +CyberPanel UI (AngularJS) + | + v +Django Views (emailDelivery/views.py) + | + v +EmailDeliveryManager (emailDelivery/emailDeliveryManager.py) + | + โ”œโ”€โ”€> CyberMail Platform API (HTTPS POST, Bearer auth) + โ”œโ”€โ”€> PowerDNS (via dnsUtilities.DNS.createDNSRecord) + โ””โ”€โ”€> Postfix (via mailUtilities.py subprocess as root) +``` + +### Design Patterns + +- **Manager Class Pattern**: All business logic in `EmailDeliveryManager`, views are thin wrappers +- **Per-User API Keys**: Each CyberPanel admin gets their own platform API key (no server-level key) +- **AngularJS SPA**: Single template with conditional rendering based on `isConnected` state +- **Subprocess for Root Operations**: Postfix relay config runs via `ProcessUtilities.outputExecutioner()` which executes as root + +--- + +## Database Models + +### CyberMailAccount (`cybermail_accounts`) + +```python +class CyberMailAccount(models.Model): + admin = OneToOneField(Administrator, CASCADE) # 1:1 with admin + platform_account_id = IntegerField(null=True) # Platform's account ID + api_key = CharField(max_length=255) # Per-user Bearer token + email = CharField(max_length=255) # Platform email + plan_name = CharField(default='Free') # Display name + plan_slug = CharField(default='free') # free/starter/professional/enterprise + emails_per_month = IntegerField(default=15000) # Plan limit + is_connected = BooleanField(default=False) # Active connection + relay_enabled = BooleanField(default=False) # Postfix relay active + smtp_credential_id = IntegerField(null=True) # Active relay credential + smtp_username = CharField(max_length=255) # Relay SMTP username + smtp_host = CharField(default='mail.cyberpersons.com') + smtp_port = IntegerField(default=587) + created_at = DateTimeField(auto_now_add=True) + updated_at = DateTimeField(auto_now=True) +``` + +### CyberMailDomain (`cybermail_domains`) + +```python +class CyberMailDomain(models.Model): + account = ForeignKey(CyberMailAccount, CASCADE) + domain = CharField(max_length=255) + platform_domain_id = IntegerField(null=True) + status = CharField(default='pending') # pending/verified + spf_verified = BooleanField(default=False) + dkim_verified = BooleanField(default=False) + dmarc_verified = BooleanField(default=False) + dns_configured = BooleanField(default=False) # Auto-configured in PowerDNS + created_at = DateTimeField(auto_now_add=True) +``` + +--- + +## API Endpoints (CyberPanel Internal) + +All endpoints are POST, require authenticated CyberPanel session, and return JSON. + +### Account Management + +| Endpoint | Method | Purpose | Request Body | +|----------|--------|---------|-------------| +| `/emailDelivery/` | GET | Render page | โ€” | +| `/emailDelivery/connect/` | POST | Register/connect account | `{email, password}` | +| `/emailDelivery/status/` | POST | Get account status + sync domains | โ€” | +| `/emailDelivery/disconnect/` | POST | Disconnect account, cleanup | โ€” | + +### Domain Management + +| Endpoint | Method | Purpose | Request Body | +|----------|--------|---------|-------------| +| `/emailDelivery/domains/add/` | POST | Add sending domain | `{domain}` | +| `/emailDelivery/domains/list/` | POST | List domains with status | โ€” | +| `/emailDelivery/domains/verify/` | POST | Verify DNS records | `{domain}` | +| `/emailDelivery/domains/dns-records/` | POST | Get required DNS records | `{domain}` | +| `/emailDelivery/domains/auto-configure-dns/` | POST | Auto-add DNS to PowerDNS | `{domain}` | +| `/emailDelivery/domains/remove/` | POST | Remove sending domain | `{domain}` | + +### SMTP Credentials + +| Endpoint | Method | Purpose | Request Body | +|----------|--------|---------|-------------| +| `/emailDelivery/smtp/create/` | POST | Create SMTP credential | `{description}` | +| `/emailDelivery/smtp/list/` | POST | List all credentials | โ€” | +| `/emailDelivery/smtp/rotate/` | POST | Rotate credential password | `{credential_id}` | +| `/emailDelivery/smtp/delete/` | POST | Delete credential | `{credential_id}` | + +### Relay + +| Endpoint | Method | Purpose | Request Body | +|----------|--------|---------|-------------| +| `/emailDelivery/relay/enable/` | POST | Enable Postfix SMTP relay | โ€” | +| `/emailDelivery/relay/disable/` | POST | Disable Postfix SMTP relay | โ€” | + +### Analytics + +| Endpoint | Method | Purpose | Request Body | +|----------|--------|---------|-------------| +| `/emailDelivery/stats/` | POST | Aggregate sending stats | โ€” | +| `/emailDelivery/stats/domains/` | POST | Per-domain stats | โ€” | +| `/emailDelivery/logs/` | POST | Paginated delivery logs | `{page, per_page, status, from_domain, days}` | + +### Health + +| Endpoint | Method | Purpose | Request Body | +|----------|--------|---------|-------------| +| `/emailDelivery/health/` | POST | Platform health check | โ€” | + +--- + +## Platform API Mapping + +Each CyberPanel endpoint maps to a platform API call: + +| CyberPanel Method | Platform Endpoint | Auth | Notes | +|-------------------|-------------------|------|-------| +| `connect()` | `api/register/` | None (public) | Returns `api_key` for future calls | +| `getStatus()` | `api/account/` + `api/domains/list/` | Bearer | Syncs plan + domains | +| `addDomain()` | `api/domains/add/` + `api/domains/dns-records/` | Bearer | Auto-configures DNS | +| `listDomains()` | `api/domains/list/` | Bearer | Syncs verification status | +| `verifyDomain()` | `api/domains/verify/` | Bearer | Returns spf/dkim/dmarc booleans | +| `getDnsRecords()` | `api/domains/dns-records/` | Bearer | Returns required records | +| `removeDomain()` | `api/domains/remove/` | Bearer | โ€” | +| `createSmtpCredential()` | `api/smtp/create/` | Bearer | Returns one-time password | +| `listSmtpCredentials()` | `api/smtp/list/` | Bearer | Normalizes `id` โ†’ `credential_id` | +| `rotateSmtpPassword()` | `api/smtp/rotate/` | Bearer | Normalizes `new_password` โ†’ `password` | +| `deleteSmtpCredential()` | `api/smtp/delete/` | Bearer | Clears relay if active credential | +| `enableRelay()` | `api/smtp/create/` or `api/smtp/rotate/` | Bearer | Then configures Postfix | +| `getStats()` | `api/stats/` | Bearer | โ€” | +| `getDomainStats()` | `api/stats/domains/` | Bearer | Converts dict โ†’ array | +| `getLogs()` | `api/logs/` | Bearer | Maps field names for JS | +| `checkStatus()` | `api/health/` | None | โ€” | + +### API Response Normalization + +The manager normalizes platform responses for frontend compatibility: + +| Platform Field | CyberPanel Field | Method | +|---------------|-----------------|--------| +| `id` (credential) | `credential_id` | `listSmtpCredentials()` | +| `new_password` | `password` | `rotateSmtpPassword()` | +| `queued_at` | `date` | `getLogs()` | +| `from_email` | `from` | `getLogs()` | +| `to_email` | `to` | `getLogs()` | +| `domains` (dict) | `domains` (array) | `getDomainStats()` | + +--- + +## Connection Flow + +``` +1. User clicks "Get Started Free" +2. JS sends POST /emailDelivery/connect/ {email, password} +3. Manager calls platform POST api/register/ (no auth) +4. Platform returns {success, data: {api_key, account_id, plan_name, ...}} +5. Manager creates/updates CyberMailAccount with api_key +6. All subsequent calls use Authorization: Bearer +``` + +### Reconnection Behavior + +When connecting with an existing `CyberMailAccount` record: +- Updates email, api_key, platform_account_id, plan info +- Resets: `smtp_credential_id=None`, `smtp_username=''`, `relay_enabled=False` +- Deletes all local `CyberMailDomain` records (stale data) + +### Disconnection Behavior + +- Disables Postfix relay if active +- Clears: `is_connected`, `relay_enabled`, `api_key`, `smtp_credential_id`, `smtp_username`, `platform_account_id` +- Deletes all local domain records +- Does NOT delete the platform account + +--- + +## DNS Auto-Configuration + +### Flow + +``` +1. User adds domain via addDomain() +2. Manager calls platform api/domains/add/ +3. Manager calls _autoConfigureDnsForDomain() +4. โ†’ Finds domain zone in PowerDNS (dns.models.Domains) +5. โ†’ Calls platform api/domains/dns-records/ to get required records +6. โ†’ For each record: DNS.createDNSRecord(zone, host, type, value, priority, ttl) +7. โ†’ Marks CyberMailDomain.dns_configured = True +8. User clicks "Verify" โ†’ calls platform api/domains/verify/ +9. Platform checks DNS โ†’ returns spf/dkim/dmarc booleans +``` + +### DNS Record Types Created + +| Record | Type | Example Value | +|--------|------|---------------| +| SPF | TXT | `v=spf1 include:spf.cyberpersons.com ~all` | +| DKIM | TXT | `v=DKIM1; k=rsa; p=MIIBIjANBg...` | +| DMARC | TXT | `v=DMARC1; p=quarantine; rua=mailto:dmarc@...` | + +### When Auto-Configuration Fails + +- Domain not in PowerDNS โ†’ returns message to add records manually +- Platform API unreachable โ†’ returns connection error +- Individual record creation fails โ†’ logged, continues with remaining records + +--- + +## SMTP Relay Configuration + +### Enable Relay Flow + +``` +1. Manager checks account.smtp_credential_id +2. If no credential: + โ†’ POST api/smtp/create/ {email, description: "CyberPanel Relay"} + โ†’ Stores credential_id, username, gets one-time password +3. If credential exists: + โ†’ POST api/smtp/rotate/ {email, credential_id} + โ†’ Gets new_password +4. Calls subprocess: python mailUtilities.py configureRelayHost + --smtpHost mail.cyberpersons.com --smtpPort 587 + --smtpUser --smtpPassword +5. mailUtilities.py (runs as root): + โ†’ Writes /etc/postfix/main.cf relay lines + โ†’ Writes /etc/postfix/sasl_passwd + โ†’ chmod 600, postmap, systemctl reload postfix +6. Sets account.relay_enabled = True +``` + +### Postfix Configuration Applied + +```ini +# Added to /etc/postfix/main.cf +relayhost = [mail.cyberpersons.com]:587 +smtp_sasl_auth_enable = yes +smtp_sasl_password_maps = hash:/etc/postfix/sasl_passwd +smtp_sasl_security_options = noanonymous +smtp_tls_security_level = encrypt +``` + +``` +# /etc/postfix/sasl_passwd +[mail.cyberpersons.com]:587 username:password +``` + +### Disable Relay + +``` +1. Calls subprocess: python mailUtilities.py removeRelayHost +2. mailUtilities.py removes relay lines from main.cf +3. Restores smtp_tls_security_level = may +4. Deletes sasl_passwd and .db files +5. Reloads Postfix +6. Sets account.relay_enabled = False +``` + +### Output Parsing + +The subprocess prints `1,None` on success. The manager checks for `'1,None' in output` (not `startswith`) because Python SyntaxWarnings may appear before the success output. + +--- + +## Frontend Architecture + +### Template: `emailDelivery/templates/emailDelivery/index.html` + +- Extends `baseTemplate/index.html` +- Uses AngularJS 1.6.5 with `{$ $}` interpolation (not `{{ }}`) +- CSS classes use `ed-` prefix +- Two views controlled by Django `{% if isConnected %}`: + - Marketing landing page (not connected) + - Dashboard with tabs (connected) + +### Controller: `emailDeliveryCtrl` + +Located in `emailDelivery/static/emailDelivery/emailDelivery.js` + +Key state variables: +```javascript +$scope.isConnected // Boolean โ€” dashboard vs marketing view +$scope.activeTab // 'domains' | 'smtp' | 'relay' | 'logs' | 'stats' +$scope.account // Account object from getStatus +$scope.domains // Array of domain objects +$scope.smtpCredentials // Array of SMTP credential objects +$scope.stats // Aggregate stats object +$scope.domainStats // Array of per-domain stats +$scope.logs // Array of log entries +$scope.logFilters // {status, from_domain, days} +$scope.logsPage // Current log page number +$scope.logsTotalPages // Total log pages +``` + +### Modal Handling + +Bootstrap 3 modals are placed OUTSIDE the `ng-controller` div (AngularJS scope limitation). Modal forms use jQuery + `onclick` handlers that call standalone functions (`cmConnect()`, `cmAddDomain()`, etc.) which make AJAX calls with `$.ajax()` and CSRF tokens. + +### CSRF Token + +All AJAX calls include the CSRF token from cookies: +```javascript +function getCookie(name) { + var value = "; " + document.cookie; + var parts = value.split("; " + name + "="); + if (parts.length == 2) return parts.pop().split(";").shift(); +} +``` + +--- + +## Error Handling + +### API Errors +- Connection timeout: 30-second timeout on all platform API calls +- Connection errors: caught and returned as `{success: false, error: "Could not connect..."}` +- Missing API key: `{success: false, error: "No API key found. Please reconnect your account."}` + +### Logging +All errors logged via `CyberCPLogFileWriter.writeToFile()` with format: +``` +[EmailDeliveryManager.] Error: +``` + +Log file: `/home/cyberpanel/error-logs.txt` + +--- + +## Banner System + +Promotional banners appear on 6 email-related pages: + +| Page | Template Path | +|------|--------------| +| Mail Functions | `mailServer/templates/mailServer/index.html` | +| Create Email | `mailServer/templates/mailServer/createEmailAccount.html` | +| DKIM Manager | `mailServer/templates/mailServer/dkimManager.html` | +| Webmail | `webmail/templates/webmail/index.html` | +| Email Premium | `emailPremium/templates/emailPremium/emailPage.html` | +| Email Marketing | `emailMarketing/templates/emailMarketing/emailMarketing.html` | + +### Banner Behavior +- Hidden by default (`display:none`) +- Shown via JS if `cybermail_dismiss=1` cookie is NOT present +- Dismiss button sets cookie with 7-day expiry (`max-age=604800`) +- Links to `/emailDelivery/` + +--- + +## File Structure + +``` +emailDelivery/ +โ”œโ”€โ”€ __init__.py +โ”œโ”€โ”€ apps.py # Django app config +โ”œโ”€โ”€ models.py # CyberMailAccount, CyberMailDomain +โ”œโ”€โ”€ views.py # Thin view wrappers (18 endpoints) +โ”œโ”€โ”€ urls.py # URL patterns +โ”œโ”€โ”€ emailDeliveryManager.py # Core business logic (~743 lines) +โ”œโ”€โ”€ migrations/ +โ”‚ โ””โ”€โ”€ __init__.py +โ”œโ”€โ”€ static/ +โ”‚ โ””โ”€โ”€ emailDelivery/ +โ”‚ โ””โ”€โ”€ emailDelivery.js # AngularJS controller +โ””โ”€โ”€ templates/ + โ””โ”€โ”€ emailDelivery/ + โ””โ”€โ”€ index.html # SPA template (marketing + dashboard) +``` + +### Related Files + +| File | Modifications | +|------|--------------| +| `CyberCP/settings.py` | Added `'emailDelivery'` to `INSTALLED_APPS` | +| `CyberCP/urls.py` | Added `path('emailDelivery/', include('emailDelivery.urls'))` | +| `plogical/mailUtilities.py` | Added `configureRelayHost()` and `removeRelayHost()` static methods + argparse args | diff --git a/docs/CYBERMAIL_USER_GUIDE.md b/docs/CYBERMAIL_USER_GUIDE.md new file mode 100644 index 000000000..1ee16eaf5 --- /dev/null +++ b/docs/CYBERMAIL_USER_GUIDE.md @@ -0,0 +1,188 @@ +# CyberMail Email Delivery โ€” User Guide + +**For**: CyberPanel users and resellers +**Last Updated**: 2026-03-06 + +--- + +## What is CyberMail? + +CyberMail is CyberPanel's built-in email delivery service. It routes your outgoing emails through optimized servers so they land in the inbox instead of spam. Every CyberPanel installation includes CyberMail with a free tier of 15,000 emails per month. + +--- + +## Quick Start + +### 1. Open CyberMail + +Log into CyberPanel and navigate to: **Email > Email Delivery** or go directly to `https://your-server:8090/emailDelivery/` + +### 2. Create Your Account + +- Click **"Get Started Free"** +- Enter your email address +- Choose a password +- Click **Connect** + +You'll immediately get access to the dashboard with the Free plan (15,000 emails/month). + +### 3. Add Your Domain + +- Go to the **Domains** tab +- Click **"Add Domain"** +- Type your domain name (e.g., `mydomain.com`) +- Click **Add** + +CyberMail will automatically set up the DNS records needed for email delivery (SPF, DKIM, DMARC). If your domain's DNS is managed by CyberPanel's PowerDNS, this happens instantly. + +### 4. Verify Your Domain + +- Click **"Verify"** next to your domain +- Check that SPF, DKIM, and DMARC all show green checkmarks +- If any are red, wait a few minutes for DNS propagation and verify again + +### 5. Start Sending + +Once your domain is verified, emails sent from that domain will benefit from CyberMail's delivery optimization. For maximum deliverability, enable the SMTP relay. + +--- + +## Features + +### Domain Management + +Add multiple sending domains to your CyberMail account. Each domain gets: + +- **SPF record** โ€” tells receivers your emails are authorized +- **DKIM signing** โ€” cryptographically signs your emails to prevent tampering +- **DMARC policy** โ€” instructs receivers how to handle unauthenticated emails + +**Status indicators:** +- Gray badge = Not verified +- Green badge = Verified and active + +**Actions:** +- **Verify** โ€” recheck DNS records +- **Auto DNS** โ€” reconfigure DNS records in PowerDNS (if records were deleted) +- **Remove** โ€” remove the domain from CyberMail + +### SMTP Credentials + +Create credentials for sending emails through CyberMail's SMTP servers: + +- **Create** โ€” generates a username and one-time password +- **Rotate** โ€” generates a new password (old one stops working) +- **Delete** โ€” permanently removes the credential + +> **Important**: The password is shown only once when created or rotated. Copy it immediately. + +**SMTP Settings for manual configuration:** +| Setting | Value | +|---------|-------| +| Host | `mail.cyberpersons.com` | +| Port | `587` | +| Security | STARTTLS | +| Authentication | Login (SASL) | +| Username | Shown after creation | +| Password | Shown once after creation | + +### SMTP Relay + +The easiest way to use CyberMail โ€” route ALL outgoing email from your server automatically: + +- **Enable** โ€” one click to configure everything +- **Disable** โ€” one click to revert to direct sending + +When enabled, every email your server sends (from all websites, all email accounts) goes through CyberMail. No application-level changes needed. + +### Delivery Logs + +Monitor every email sent through CyberMail: + +- **Filter by status**: All, Delivered, Bounced, Failed, Deferred +- **Filter by time**: Last 1, 3, 7, 14, or 30 days +- **View details**: Date, sender, recipient, subject, delivery status + +### Statistics + +Track your email performance: + +- **Total Sent** โ€” emails sent this billing period +- **Delivered** โ€” successfully delivered to recipient +- **Bounced** โ€” rejected by recipient server +- **Failed** โ€” permanent delivery failures +- **Delivery Rate** โ€” percentage of successful deliveries +- **Per-Domain Breakdown** โ€” stats for each sending domain + +### Usage Tracking + +The dashboard shows a progress bar of your monthly email usage: +- Green = under 80% usage +- Yellow/warning = approaching limit +- An upgrade banner appears when you're near your plan limit + +--- + +## Plans + +| | Free | Starter | Professional | Enterprise | +|---|---|---|---|---| +| **Price** | $0/mo | $15/mo | $90/mo | $299/mo | +| **Emails** | 15,000 | 100,000 | 500,000 | 2,000,000 | +| **Infrastructure** | Shared | Shared | Dedicated IPs | Dedicated IPs | +| **Analytics** | Basic | Advanced | Advanced | Advanced | +| **Support** | Community | Priority | Priority | Dedicated Manager | +| **Custom DKIM** | No | No | Yes | Yes | +| **Webhooks** | No | No | Yes | Yes | +| **SLA** | โ€” | โ€” | โ€” | 99.9% | + +To upgrade, visit the CyberMail platform at https://platform.cyberpersons.com + +--- + +## Disconnecting Your Account + +If you need to disconnect CyberMail: + +1. Go to the CyberMail dashboard +2. Click the **"Disconnect"** button +3. Confirm the action + +**What happens:** +- SMTP relay is disabled (if it was enabled) +- Postfix returns to direct sending +- Local data (domains, credentials) is cleared +- Your platform account is preserved โ€” you can reconnect anytime + +--- + +## FAQ + +**Q: Will enabling relay affect my existing email accounts?** +A: Yes, ALL outgoing email from the server will route through CyberMail. This includes emails from websites (contact forms, notifications) and email accounts (Postfix). Incoming email is not affected. + +**Q: Can I use CyberMail without the relay?** +A: Yes. You can use SMTP credentials directly in your applications (WordPress SMTP plugins, custom scripts, etc.) without enabling the server-wide relay. + +**Q: What happens if I exceed my plan limit?** +A: Check with the platform for current overage policies. The dashboard shows your usage so you can monitor and upgrade before hitting limits. + +**Q: Can I use CyberMail for bulk marketing emails?** +A: CyberMail is designed for transactional and legitimate business email. Bulk marketing to purchased lists is not permitted. Use it for newsletters to opted-in subscribers, transactional emails, and business communications. + +**Q: My domain DNS is not managed by CyberPanel. Can I still use CyberMail?** +A: Yes. After adding the domain, click "DNS Records" to see the required records. Add them manually at your DNS provider (Cloudflare, Route53, etc.). + +**Q: I disconnected and reconnected. Why are my old domains gone?** +A: Reconnecting clears stale local data. Simply add your domains again โ€” if they're still registered on the platform, they'll link back. + +**Q: Is there a banner on other pages?** +A: Yes, a promotional banner appears on email-related pages (Webmail, Mail Functions, etc.). It can be dismissed by clicking the X button and won't reappear for 7 days. + +--- + +## Support + +- **CyberPanel Issues**: https://github.com/usmannasir/cyberpanel/issues +- **CyberMail Platform**: https://platform.cyberpersons.com +- **Email Deliverability Help**: Check your domain at https://www.mail-tester.com diff --git a/docs/RESOURCE_LIMITS_GUIDE.md b/docs/RESOURCE_LIMITS_GUIDE.md index ba371f4d8..dd5b87b6e 100644 --- a/docs/RESOURCE_LIMITS_GUIDE.md +++ b/docs/RESOURCE_LIMITS_GUIDE.md @@ -21,7 +21,7 @@ CyberPanel now supports comprehensive resource limits for shared hosting environ - Ubuntu 20.04+, Debian 11+, CentOS Stream 9+, AlmaLinux 9+, Rocky Linux 9+ - RHEL 8 family (RHEL 8, AlmaLinux 8, Rocky 8, CloudLinux 8) with cgroups v2 manually enabled -2. **CyberPanel Version**: v2.4.4-dev or later +2. **CyberPanel Version**: v2.4.5 or later 3. **OpenLiteSpeed**: Installed and running diff --git a/emailDelivery/__init__.py b/emailDelivery/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/emailDelivery/apps.py b/emailDelivery/apps.py new file mode 100644 index 000000000..a8004a8cd --- /dev/null +++ b/emailDelivery/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class EmaildeliveryConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'emailDelivery' diff --git a/emailDelivery/emailDeliveryManager.py b/emailDelivery/emailDeliveryManager.py new file mode 100644 index 000000000..0d608e789 --- /dev/null +++ b/emailDelivery/emailDeliveryManager.py @@ -0,0 +1,747 @@ +import json +import requests +from django.http import JsonResponse +from loginSystem.models import Administrator +from plogical.acl import ACLManager +from plogical.CyberCPLogFileWriter import CyberCPLogFileWriter as logging +from plogical.httpProc import httpProc +from plogical.processUtilities import ProcessUtilities +from .models import CyberMailAccount, CyberMailDomain + + +class EmailDeliveryManager: + + PLATFORM_URL = 'https://platform.cyberpersons.com/email/cp/' + + def __init__(self): + self.logger = logging + + def _apiCall(self, endpoint, data=None, apiKey=None): + """POST to platform API. If apiKey provided, sends Bearer auth.""" + headers = {'Content-Type': 'application/json'} + if apiKey: + headers['Authorization'] = 'Bearer %s' % apiKey + url = self.PLATFORM_URL + endpoint + try: + resp = requests.post(url, json=data or {}, headers=headers, timeout=30) + return resp.json() + except requests.exceptions.Timeout: + return {'success': False, 'error': 'Platform API request timed out.'} + except requests.exceptions.ConnectionError: + return {'success': False, 'error': 'Could not connect to CyberMail platform.'} + except Exception as e: + return {'success': False, 'error': str(e)} + + def _accountApiCall(self, account, endpoint, data=None): + """API call using a CyberMailAccount's stored per-user key.""" + if not account.api_key: + return {'success': False, 'error': 'No API key found. Please reconnect your account.'} + return self._apiCall(endpoint, data, apiKey=account.api_key) + + def home(self, request, userID): + try: + admin = Administrator.objects.get(pk=userID) + + isConnected = False + try: + account = CyberMailAccount.objects.get(admin=admin) + isConnected = account.is_connected + except CyberMailAccount.DoesNotExist: + pass + + data = { + 'isConnected': isConnected, + 'adminEmail': admin.email, + 'adminName': admin.firstName if hasattr(admin, 'firstName') else admin.userName, + } + + proc = httpProc(request, 'emailDelivery/index.html', data, 'admin') + return proc.render() + + except Exception as e: + self.logger.writeToFile('[EmailDeliveryManager.home] Error: %s' % str(e)) + proc = httpProc(request, 'emailDelivery/index.html', { + 'isConnected': False, + 'adminEmail': '', + 'adminName': '', + }) + return proc.render() + + def getStatus(self, request, userID): + try: + admin = Administrator.objects.get(pk=userID) + account = CyberMailAccount.objects.get(admin=admin, is_connected=True) + + result = self._accountApiCall(account, 'api/account/', {'email': account.email}) + if not result.get('success', False): + return JsonResponse({'success': False, 'error': result.get('error', 'Failed to get account status')}) + + accountData = result.get('data', {}) + + # Platform returns plan info nested under data.plan + planInfo = accountData.get('plan', {}) + if planInfo.get('name'): + account.plan_name = planInfo['name'] + account.plan_slug = planInfo.get('slug', account.plan_slug) + account.emails_per_month = planInfo.get('emails_per_month', account.emails_per_month) + account.save() + + # Sync domains from platform to local DB + try: + domainResult = self._accountApiCall(account, 'api/domains/list/', {'email': account.email}) + if domainResult.get('success', False): + platformDomains = domainResult.get('data', {}).get('domains', []) + for pd in platformDomains: + try: + cmDomain = CyberMailDomain.objects.get(account=account, domain=pd['domain']) + cmDomain.status = pd.get('status', cmDomain.status) + cmDomain.spf_verified = pd.get('spf_verified', False) + cmDomain.dkim_verified = pd.get('dkim_verified', False) + cmDomain.dmarc_verified = pd.get('dmarc_verified', False) + cmDomain.save() + except CyberMailDomain.DoesNotExist: + CyberMailDomain.objects.create( + account=account, + domain=pd['domain'], + platform_domain_id=pd.get('id'), + status=pd.get('status', 'pending'), + spf_verified=pd.get('spf_verified', False), + dkim_verified=pd.get('dkim_verified', False), + dmarc_verified=pd.get('dmarc_verified', False), + ) + except Exception as e: + self.logger.writeToFile('[EmailDeliveryManager.getStatus] Domain sync error: %s' % str(e)) + + domains = list(CyberMailDomain.objects.filter(account=account).values( + 'id', 'domain', 'platform_domain_id', 'status', + 'spf_verified', 'dkim_verified', 'dmarc_verified', 'dns_configured' + )) + + return JsonResponse({ + 'success': True, + 'account': { + 'email': account.email, + 'plan_name': account.plan_name, + 'plan_slug': account.plan_slug, + 'emails_per_month': account.emails_per_month, + 'relay_enabled': account.relay_enabled, + 'smtp_host': account.smtp_host, + 'smtp_port': account.smtp_port, + }, + 'domains': domains, + 'stats': { + 'emails_sent': accountData.get('emails_sent_this_month', 0), + 'reputation_score': accountData.get('reputation_score', 0), + }, + }) + + except CyberMailAccount.DoesNotExist: + return JsonResponse({'success': False, 'error': 'Account not connected'}) + except Exception as e: + self.logger.writeToFile('[EmailDeliveryManager.getStatus] Error: %s' % str(e)) + return JsonResponse({'success': False, 'error': str(e)}) + + def connect(self, request, userID): + try: + admin = Administrator.objects.get(pk=userID) + data = json.loads(request.body) + password = data.get('password', '') + email = data.get('email', admin.email) + + if not password: + return JsonResponse({'success': False, 'error': 'Password is required'}) + + fullName = admin.firstName if hasattr(admin, 'firstName') and admin.firstName else admin.userName + + # Public endpoint โ€” no API key needed for registration + result = self._apiCall('api/register/', { + 'email': email, + 'password': password, + 'full_name': fullName, + }) + + if not result.get('success', False): + return JsonResponse({'success': False, 'error': result.get('error', 'Registration failed')}) + + accountData = result.get('data', {}) + apiKey = accountData.get('api_key', '') + + account, created = CyberMailAccount.objects.get_or_create( + admin=admin, + defaults={ + 'email': email, + 'api_key': apiKey, + 'platform_account_id': accountData.get('account_id'), + 'plan_name': accountData.get('plan_name', 'Free'), + 'plan_slug': accountData.get('plan_slug', 'free'), + 'emails_per_month': accountData.get('emails_per_month', 15000), + 'is_connected': True, + } + ) + + if not created: + account.email = email + account.api_key = apiKey + account.platform_account_id = accountData.get('account_id') + account.plan_name = accountData.get('plan_name', 'Free') + account.plan_slug = accountData.get('plan_slug', 'free') + account.emails_per_month = accountData.get('emails_per_month', 15000) + account.is_connected = True + # Reset relay fields from previous account + account.smtp_credential_id = None + account.smtp_username = '' + account.relay_enabled = False + account.save() + # Clear stale domains from previous account + CyberMailDomain.objects.filter(account=account).delete() + + return JsonResponse({'success': True, 'message': 'Connected to CyberMail successfully'}) + + except Exception as e: + self.logger.writeToFile('[EmailDeliveryManager.connect] Error: %s' % str(e)) + return JsonResponse({'success': False, 'error': str(e)}) + + def addDomain(self, request, userID): + try: + admin = Administrator.objects.get(pk=userID) + account = CyberMailAccount.objects.get(admin=admin, is_connected=True) + data = json.loads(request.body) + domainName = data.get('domain', '') + + if not domainName: + return JsonResponse({'success': False, 'error': 'Domain name is required'}) + + result = self._accountApiCall(account, 'api/domains/add/', { + 'email': account.email, + 'domain': domainName, + }) + + if not result.get('success', False): + return JsonResponse({'success': False, 'error': result.get('error', 'Failed to add domain')}) + + domainData = result.get('data', {}) + + cmDomain, created = CyberMailDomain.objects.get_or_create( + account=account, + domain=domainName, + defaults={ + 'platform_domain_id': domainData.get('id') or domainData.get('domain_id'), + 'status': domainData.get('status', 'pending'), + } + ) + if not created: + cmDomain.platform_domain_id = domainData.get('id') or domainData.get('domain_id') + cmDomain.status = domainData.get('status', 'pending') + cmDomain.save() + + # Auto-configure DNS if domain exists in PowerDNS + dnsResult = self._autoConfigureDnsForDomain(account, domainName) + + return JsonResponse({ + 'success': True, + 'message': 'Domain added successfully', + 'dns_configured': dnsResult.get('success', False), + 'dns_message': dnsResult.get('message', ''), + }) + + except CyberMailAccount.DoesNotExist: + return JsonResponse({'success': False, 'error': 'Account not connected'}) + except Exception as e: + self.logger.writeToFile('[EmailDeliveryManager.addDomain] Error: %s' % str(e)) + return JsonResponse({'success': False, 'error': str(e)}) + + def _autoConfigureDnsForDomain(self, account, domainName): + try: + from dns.models import Domains as dnsDomains + from plogical.dnsUtilities import DNS + + try: + zone = dnsDomains.objects.get(name=domainName) + except dnsDomains.DoesNotExist: + return {'success': False, 'message': 'Domain not found in PowerDNS. Please add DNS records manually.'} + + recordsResult = self._accountApiCall(account, 'api/domains/dns-records/', { + 'email': account.email, + 'domain': domainName, + }) + + if not recordsResult.get('success', False): + return {'success': False, 'message': 'Could not fetch DNS records from platform.'} + + records = recordsResult.get('data', {}).get('records', []) + added = 0 + for rec in records: + try: + # Platform returns 'host' for the DNS hostname, 'type' for record type, 'value' for content + recordHost = rec.get('host', '') + recordType = rec.get('type', '') + recordValue = rec.get('value', '') + + if not recordHost or not recordType or not recordValue: + continue + + DNS.createDNSRecord( + zone, + recordHost, + recordType, + recordValue, + rec.get('priority', 0), + rec.get('ttl', 3600) + ) + added += 1 + except Exception as e: + self.logger.writeToFile('[EmailDeliveryManager._autoConfigureDnsForDomain] Record error: %s' % str(e)) + + try: + cmDomain = CyberMailDomain.objects.get(account=account, domain=domainName) + cmDomain.dns_configured = True + cmDomain.save() + except CyberMailDomain.DoesNotExist: + pass + + return {'success': True, 'message': '%d DNS records configured automatically.' % added} + + except Exception as e: + self.logger.writeToFile('[EmailDeliveryManager._autoConfigureDnsForDomain] Error: %s' % str(e)) + return {'success': False, 'message': str(e)} + + def verifyDomain(self, request, userID): + try: + admin = Administrator.objects.get(pk=userID) + account = CyberMailAccount.objects.get(admin=admin, is_connected=True) + data = json.loads(request.body) + domainName = data.get('domain', '') + + result = self._accountApiCall(account, 'api/domains/verify/', { + 'email': account.email, + 'domain': domainName, + }) + + if not result.get('success', False): + return JsonResponse({'success': False, 'error': result.get('error', 'Verification failed')}) + + verifyData = result.get('data', {}) + + # Platform returns: spf, dkim, dmarc, all_verified, verification_token + allVerified = verifyData.get('all_verified', False) + + try: + cmDomain = CyberMailDomain.objects.get(account=account, domain=domainName) + cmDomain.status = 'verified' if allVerified else 'pending' + cmDomain.spf_verified = verifyData.get('spf', False) + cmDomain.dkim_verified = verifyData.get('dkim', False) + cmDomain.dmarc_verified = verifyData.get('dmarc', False) + cmDomain.save() + except CyberMailDomain.DoesNotExist: + pass + + return JsonResponse({'success': True, 'data': { + 'status': 'verified' if allVerified else 'pending', + 'spf_verified': verifyData.get('spf', False), + 'dkim_verified': verifyData.get('dkim', False), + 'dmarc_verified': verifyData.get('dmarc', False), + 'all_verified': allVerified, + }}) + + except CyberMailAccount.DoesNotExist: + return JsonResponse({'success': False, 'error': 'Account not connected'}) + except Exception as e: + self.logger.writeToFile('[EmailDeliveryManager.verifyDomain] Error: %s' % str(e)) + return JsonResponse({'success': False, 'error': str(e)}) + + def listDomains(self, request, userID): + try: + admin = Administrator.objects.get(pk=userID) + account = CyberMailAccount.objects.get(admin=admin, is_connected=True) + + result = self._accountApiCall(account, 'api/domains/list/', {'email': account.email}) + if not result.get('success', False): + return JsonResponse({'success': False, 'error': result.get('error', 'Failed to list domains')}) + + platformDomains = result.get('data', {}).get('domains', []) + + for pd in platformDomains: + try: + cmDomain = CyberMailDomain.objects.get(account=account, domain=pd['domain']) + cmDomain.status = pd.get('status', cmDomain.status) + cmDomain.spf_verified = pd.get('spf_verified', False) + cmDomain.dkim_verified = pd.get('dkim_verified', False) + cmDomain.dmarc_verified = pd.get('dmarc_verified', False) + cmDomain.save() + except CyberMailDomain.DoesNotExist: + CyberMailDomain.objects.create( + account=account, + domain=pd['domain'], + platform_domain_id=pd.get('id'), + status=pd.get('status', 'pending'), + spf_verified=pd.get('spf_verified', False), + dkim_verified=pd.get('dkim_verified', False), + dmarc_verified=pd.get('dmarc_verified', False), + ) + + domains = list(CyberMailDomain.objects.filter(account=account).values( + 'id', 'domain', 'platform_domain_id', 'status', + 'spf_verified', 'dkim_verified', 'dmarc_verified', 'dns_configured' + )) + + return JsonResponse({'success': True, 'domains': domains}) + + except CyberMailAccount.DoesNotExist: + return JsonResponse({'success': False, 'error': 'Account not connected'}) + except Exception as e: + self.logger.writeToFile('[EmailDeliveryManager.listDomains] Error: %s' % str(e)) + return JsonResponse({'success': False, 'error': str(e)}) + + def getDnsRecords(self, request, userID): + try: + admin = Administrator.objects.get(pk=userID) + account = CyberMailAccount.objects.get(admin=admin, is_connected=True) + data = json.loads(request.body) + domainName = data.get('domain', '') + + result = self._accountApiCall(account, 'api/domains/dns-records/', { + 'email': account.email, + 'domain': domainName, + }) + + return JsonResponse(result) + + except CyberMailAccount.DoesNotExist: + return JsonResponse({'success': False, 'error': 'Account not connected'}) + except Exception as e: + self.logger.writeToFile('[EmailDeliveryManager.getDnsRecords] Error: %s' % str(e)) + return JsonResponse({'success': False, 'error': str(e)}) + + def removeDomain(self, request, userID): + try: + admin = Administrator.objects.get(pk=userID) + account = CyberMailAccount.objects.get(admin=admin, is_connected=True) + data = json.loads(request.body) + domainName = data.get('domain', '') + + result = self._accountApiCall(account, 'api/domains/remove/', { + 'email': account.email, + 'domain': domainName, + }) + + if not result.get('success', False): + return JsonResponse({'success': False, 'error': result.get('error', 'Failed to remove domain')}) + + CyberMailDomain.objects.filter(account=account, domain=domainName).delete() + + return JsonResponse({'success': True, 'message': 'Domain removed successfully'}) + + except CyberMailAccount.DoesNotExist: + return JsonResponse({'success': False, 'error': 'Account not connected'}) + except Exception as e: + self.logger.writeToFile('[EmailDeliveryManager.removeDomain] Error: %s' % str(e)) + return JsonResponse({'success': False, 'error': str(e)}) + + def autoConfigureDns(self, request, userID): + try: + admin = Administrator.objects.get(pk=userID) + account = CyberMailAccount.objects.get(admin=admin, is_connected=True) + data = json.loads(request.body) + domainName = data.get('domain', '') + + result = self._autoConfigureDnsForDomain(account, domainName) + + return JsonResponse({ + 'success': result.get('success', False), + 'message': result.get('message', ''), + }) + + except CyberMailAccount.DoesNotExist: + return JsonResponse({'success': False, 'error': 'Account not connected'}) + except Exception as e: + self.logger.writeToFile('[EmailDeliveryManager.autoConfigureDns] Error: %s' % str(e)) + return JsonResponse({'success': False, 'error': str(e)}) + + def createSmtpCredential(self, request, userID): + try: + admin = Administrator.objects.get(pk=userID) + account = CyberMailAccount.objects.get(admin=admin, is_connected=True) + data = json.loads(request.body) + + result = self._accountApiCall(account, 'api/smtp/create/', { + 'email': account.email, + 'description': data.get('description', ''), + }) + + return JsonResponse(result) + + except CyberMailAccount.DoesNotExist: + return JsonResponse({'success': False, 'error': 'Account not connected'}) + except Exception as e: + self.logger.writeToFile('[EmailDeliveryManager.createSmtpCredential] Error: %s' % str(e)) + return JsonResponse({'success': False, 'error': str(e)}) + + def listSmtpCredentials(self, request, userID): + try: + admin = Administrator.objects.get(pk=userID) + account = CyberMailAccount.objects.get(admin=admin, is_connected=True) + + result = self._accountApiCall(account, 'api/smtp/list/', {'email': account.email}) + + # Normalize: platform returns 'id' per credential, JS expects 'credential_id' + if result.get('success') and result.get('data', {}).get('credentials'): + for cred in result['data']['credentials']: + if 'id' in cred and 'credential_id' not in cred: + cred['credential_id'] = cred['id'] + + return JsonResponse(result) + + except CyberMailAccount.DoesNotExist: + return JsonResponse({'success': False, 'error': 'Account not connected'}) + except Exception as e: + self.logger.writeToFile('[EmailDeliveryManager.listSmtpCredentials] Error: %s' % str(e)) + return JsonResponse({'success': False, 'error': str(e)}) + + def rotateSmtpPassword(self, request, userID): + try: + admin = Administrator.objects.get(pk=userID) + account = CyberMailAccount.objects.get(admin=admin, is_connected=True) + data = json.loads(request.body) + + result = self._accountApiCall(account, 'api/smtp/rotate/', { + 'email': account.email, + 'credential_id': data.get('credential_id'), + }) + + # Normalize: platform returns 'new_password', JS expects 'password' + if result.get('success') and result.get('data', {}).get('new_password'): + result['data']['password'] = result['data'].pop('new_password') + + return JsonResponse(result) + + except CyberMailAccount.DoesNotExist: + return JsonResponse({'success': False, 'error': 'Account not connected'}) + except Exception as e: + self.logger.writeToFile('[EmailDeliveryManager.rotateSmtpPassword] Error: %s' % str(e)) + return JsonResponse({'success': False, 'error': str(e)}) + + def deleteSmtpCredential(self, request, userID): + try: + admin = Administrator.objects.get(pk=userID) + account = CyberMailAccount.objects.get(admin=admin, is_connected=True) + data = json.loads(request.body) + + result = self._accountApiCall(account, 'api/smtp/delete/', { + 'email': account.email, + 'credential_id': data.get('credential_id'), + }) + + if result.get('success', False): + if account.smtp_credential_id == data.get('credential_id'): + account.smtp_credential_id = None + account.smtp_username = '' + account.save() + + return JsonResponse(result) + + except CyberMailAccount.DoesNotExist: + return JsonResponse({'success': False, 'error': 'Account not connected'}) + except Exception as e: + self.logger.writeToFile('[EmailDeliveryManager.deleteSmtpCredential] Error: %s' % str(e)) + return JsonResponse({'success': False, 'error': str(e)}) + + def enableRelay(self, request, userID): + try: + admin = Administrator.objects.get(pk=userID) + account = CyberMailAccount.objects.get(admin=admin, is_connected=True) + + # Create SMTP credential if none exists + if not account.smtp_credential_id: + result = self._accountApiCall(account, 'api/smtp/create/', { + 'email': account.email, + 'description': 'CyberPanel Relay', + }) + if not result.get('success', False): + return JsonResponse({'success': False, 'error': result.get('error', 'Failed to create SMTP credential')}) + + credData = result.get('data', {}) + account.smtp_credential_id = credData.get('credential_id') + account.smtp_username = credData.get('username', '') + account.save() + + smtpPassword = credData.get('password', '') + else: + # Rotate to get a fresh password + result = self._accountApiCall(account, 'api/smtp/rotate/', { + 'email': account.email, + 'credential_id': account.smtp_credential_id, + }) + if not result.get('success', False): + return JsonResponse({'success': False, 'error': result.get('error', 'Failed to get SMTP password')}) + + smtpPassword = result.get('data', {}).get('new_password', '') + + # Configure Postfix relay via mailUtilities subprocess + import shlex + execPath = "/usr/local/CyberCP/bin/python /usr/local/CyberCP/plogical/mailUtilities.py" \ + " configureRelayHost --smtpHost %s --smtpPort %s --smtpUser %s --smtpPassword %s" % ( + shlex.quote(str(account.smtp_host)), + shlex.quote(str(account.smtp_port)), + shlex.quote(str(account.smtp_username)), + shlex.quote(str(smtpPassword)) + ) + output = ProcessUtilities.outputExecutioner(execPath) + + if output and '1,None' in output: + account.relay_enabled = True + account.save() + return JsonResponse({'success': True, 'message': 'SMTP relay enabled successfully'}) + else: + return JsonResponse({'success': False, 'error': 'Failed to configure Postfix relay: %s' % str(output)}) + + except CyberMailAccount.DoesNotExist: + return JsonResponse({'success': False, 'error': 'Account not connected'}) + except Exception as e: + self.logger.writeToFile('[EmailDeliveryManager.enableRelay] Error: %s' % str(e)) + return JsonResponse({'success': False, 'error': str(e)}) + + def disableRelay(self, request, userID): + try: + admin = Administrator.objects.get(pk=userID) + account = CyberMailAccount.objects.get(admin=admin, is_connected=True) + + execPath = "/usr/local/CyberCP/bin/python /usr/local/CyberCP/plogical/mailUtilities.py removeRelayHost" + output = ProcessUtilities.outputExecutioner(execPath) + + if output and '1,None' in output: + account.relay_enabled = False + account.save() + return JsonResponse({'success': True, 'message': 'SMTP relay disabled successfully'}) + else: + return JsonResponse({'success': False, 'error': 'Failed to remove Postfix relay: %s' % str(output)}) + + except CyberMailAccount.DoesNotExist: + return JsonResponse({'success': False, 'error': 'Account not connected'}) + except Exception as e: + self.logger.writeToFile('[EmailDeliveryManager.disableRelay] Error: %s' % str(e)) + return JsonResponse({'success': False, 'error': str(e)}) + + def getStats(self, request, userID): + try: + admin = Administrator.objects.get(pk=userID) + account = CyberMailAccount.objects.get(admin=admin, is_connected=True) + + result = self._accountApiCall(account, 'api/stats/', {'email': account.email}) + + # Normalize for JS: platform returns data.total_sent etc at top level + if result.get('success') and result.get('data'): + d = result['data'] + result['data'] = { + 'total_sent': d.get('total_sent', 0), + 'delivered': d.get('delivered', 0), + 'bounced': d.get('bounced', 0), + 'failed': d.get('failed', 0), + 'delivery_rate': d.get('delivery_rate', 0), + } + + return JsonResponse(result) + + except CyberMailAccount.DoesNotExist: + return JsonResponse({'success': False, 'error': 'Account not connected'}) + except Exception as e: + self.logger.writeToFile('[EmailDeliveryManager.getStats] Error: %s' % str(e)) + return JsonResponse({'success': False, 'error': str(e)}) + + def getDomainStats(self, request, userID): + try: + admin = Administrator.objects.get(pk=userID) + account = CyberMailAccount.objects.get(admin=admin, is_connected=True) + + result = self._accountApiCall(account, 'api/stats/domains/', {'email': account.email}) + + # Normalize: platform returns data.domains as dict, JS expects array + if result.get('success') and result.get('data'): + domainsData = result['data'].get('domains', {}) + if isinstance(domainsData, dict): + domainsList = [] + for domainName, stats in domainsData.items(): + entry = {'domain': domainName} + if isinstance(stats, dict): + entry.update(stats) + domainsList.append(entry) + result['data']['domains'] = domainsList + + return JsonResponse(result) + + except CyberMailAccount.DoesNotExist: + return JsonResponse({'success': False, 'error': 'Account not connected'}) + except Exception as e: + self.logger.writeToFile('[EmailDeliveryManager.getDomainStats] Error: %s' % str(e)) + return JsonResponse({'success': False, 'error': str(e)}) + + def getLogs(self, request, userID): + try: + admin = Administrator.objects.get(pk=userID) + account = CyberMailAccount.objects.get(admin=admin, is_connected=True) + data = json.loads(request.body) + + result = self._accountApiCall(account, 'api/logs/', { + 'email': account.email, + 'page': data.get('page', 1), + 'per_page': data.get('per_page', 50), + 'status': data.get('status', ''), + 'from_domain': data.get('from_domain', ''), + 'days': data.get('days', 7), + }) + + # Normalize field names and pagination + if result.get('success') and result.get('data'): + pagination = result['data'].get('pagination', {}) + result['data']['total_pages'] = pagination.get('total_pages', 1) + result['data']['page'] = pagination.get('page', 1) + + # Map platform field names to what JS/template expects + logs = result['data'].get('logs', []) + for log in logs: + log['date'] = log.get('queued_at', '') + log['from'] = log.get('from_email', '') + log['to'] = log.get('to_email', '') + + return JsonResponse(result) + + except CyberMailAccount.DoesNotExist: + return JsonResponse({'success': False, 'error': 'Account not connected'}) + except Exception as e: + self.logger.writeToFile('[EmailDeliveryManager.getLogs] Error: %s' % str(e)) + return JsonResponse({'success': False, 'error': str(e)}) + + def disconnect(self, request, userID): + try: + admin = Administrator.objects.get(pk=userID) + account = CyberMailAccount.objects.get(admin=admin, is_connected=True) + + # Disable relay first if enabled + if account.relay_enabled: + execPath = "/usr/local/CyberCP/bin/python /usr/local/CyberCP/plogical/mailUtilities.py removeRelayHost" + ProcessUtilities.outputExecutioner(execPath) + + account.is_connected = False + account.relay_enabled = False + account.api_key = '' + account.smtp_credential_id = None + account.smtp_username = '' + account.platform_account_id = None + account.save() + + # Remove local domain records + CyberMailDomain.objects.filter(account=account).delete() + + return JsonResponse({'success': True, 'message': 'Disconnected from CyberMail'}) + + except CyberMailAccount.DoesNotExist: + return JsonResponse({'success': False, 'error': 'Account not connected'}) + except Exception as e: + self.logger.writeToFile('[EmailDeliveryManager.disconnect] Error: %s' % str(e)) + return JsonResponse({'success': False, 'error': str(e)}) + + def checkStatus(self, request, userID): + try: + result = self._apiCall('api/health/', {}) + return JsonResponse(result) + except Exception as e: + return JsonResponse({'success': False, 'error': str(e)}) diff --git a/emailDelivery/migrations/__init__.py b/emailDelivery/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/emailDelivery/models.py b/emailDelivery/models.py new file mode 100644 index 000000000..b1a6cdd5d --- /dev/null +++ b/emailDelivery/models.py @@ -0,0 +1,44 @@ +from django.db import models +from loginSystem.models import Administrator + + +class CyberMailAccount(models.Model): + admin = models.OneToOneField(Administrator, on_delete=models.CASCADE, related_name='cybermail_account') + platform_account_id = models.IntegerField(null=True) + api_key = models.CharField(max_length=255, blank=True) + email = models.CharField(max_length=255) + plan_name = models.CharField(max_length=100, default='Free') + plan_slug = models.CharField(max_length=50, default='free') + emails_per_month = models.IntegerField(default=15000) + is_connected = models.BooleanField(default=False) + relay_enabled = models.BooleanField(default=False) + smtp_credential_id = models.IntegerField(null=True) + smtp_username = models.CharField(max_length=255, blank=True) + smtp_host = models.CharField(max_length=255, default='mail.cyberpersons.com') + smtp_port = models.IntegerField(default=587) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = 'cybermail_accounts' + + def __str__(self): + return f"CyberMail Account for {self.admin.userName}" + + +class CyberMailDomain(models.Model): + account = models.ForeignKey(CyberMailAccount, on_delete=models.CASCADE, related_name='domains') + domain = models.CharField(max_length=255) + platform_domain_id = models.IntegerField(null=True) + status = models.CharField(max_length=50, default='pending') + spf_verified = models.BooleanField(default=False) + dkim_verified = models.BooleanField(default=False) + dmarc_verified = models.BooleanField(default=False) + dns_configured = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = 'cybermail_domains' + + def __str__(self): + return f"CyberMail Domain: {self.domain}" diff --git a/emailDelivery/static/emailDelivery/emailDelivery.js b/emailDelivery/static/emailDelivery/emailDelivery.js new file mode 100644 index 000000000..14202ad3d --- /dev/null +++ b/emailDelivery/static/emailDelivery/emailDelivery.js @@ -0,0 +1,326 @@ +app.controller('emailDeliveryCtrl', ['$scope', '$http', '$timeout', function($scope, $http, $timeout) { + + function apiCall(url, data, callback, errback) { + var config = {headers: {'X-CSRFToken': getCookie('csrftoken')}}; + $http.post(url, data || {}, config).then(function(resp) { + if (callback) callback(resp.data); + }, function(err) { + console.error('API error:', url, err); + if (errback) errback(err); + }); + } + + function notify(msg, type) { + new PNotify({title: type === 'error' ? 'Error' : 'Email Delivery', text: msg, type: type || 'success'}); + } + + $scope.loading = true; + $scope.activeTab = 'domains'; + + // Account data + $scope.account = {}; + $scope.stats = {}; + $scope.domains = []; + $scope.smtpCredentials = []; + $scope.logs = []; + $scope.detailedStats = {}; + $scope.domainStats = []; + + // Form data + $scope.connectEmail = ''; + $scope.connectPassword = ''; + $scope.connectLoading = false; + $scope.newDomainName = ''; + $scope.addDomainLoading = false; + $scope.newSmtpDescription = ''; + $scope.createSmtpLoading = false; + $scope.oneTimePassword = null; + + // Relay + $scope.relayLoading = false; + + // Logs + $scope.logFilters = {status: '', from_domain: '', days: 7}; + $scope.logsPage = 1; + $scope.logsTotalPages = 1; + $scope.logsLoading = false; + + // Domain stats + $scope.domainStatsLoading = false; + + // Disconnect + $scope.disconnectLoading = false; + + $scope.init = function(isConnected, adminEmail) { + $scope.isConnected = isConnected; + $scope.connectEmail = adminEmail || ''; + if (isConnected) { + $scope.refreshDashboard(); + } else { + $scope.loading = false; + } + }; + + $scope.refreshDashboard = function() { + $scope.loading = true; + apiCall('/emailDelivery/status/', {}, function(data) { + if (data.success) { + $scope.account = data.account; + $scope.stats = data.stats || {}; + $scope.domains = data.domains || []; + } + $scope.loading = false; + }, function() { + $scope.loading = false; + notify('Failed to load dashboard data', 'error'); + }); + }; + + $scope.getUsagePercent = function() { + if (!$scope.account.emails_per_month) return 0; + return Math.min(100, Math.round(($scope.stats.emails_sent || 0) / $scope.account.emails_per_month * 100)); + }; + + // ============ Connect ============ + $scope.connectAccount = function() { + if (!$scope.connectEmail || !$scope.connectPassword) { + notify('Please fill in all fields.', 'error'); + return; + } + $scope.connectLoading = true; + apiCall('/emailDelivery/connect/', { + email: $scope.connectEmail, + password: $scope.connectPassword + }, function(data) { + $scope.connectLoading = false; + if (data.success) { + $('#connectModal').modal('hide'); + notify('Connected to CyberMail successfully!'); + $timeout(function() { window.location.reload(); }, 1000); + } else { + notify(data.error || 'Connection failed.', 'error'); + } + }, function() { + $scope.connectLoading = false; + notify('Connection failed. Please try again.', 'error'); + }); + }; + + // ============ Domains ============ + $scope.addDomain = function() { + if (!$scope.newDomainName) { + notify('Please enter a domain name.', 'error'); + return; + } + $scope.addDomainLoading = true; + apiCall('/emailDelivery/domains/add/', { + domain: $scope.newDomainName + }, function(data) { + $scope.addDomainLoading = false; + if (data.success) { + $('#addDomainModal').modal('hide'); + $scope.newDomainName = ''; + $scope.refreshDashboard(); + var msg = 'Domain added successfully.'; + if (data.dns_configured) msg += ' ' + data.dns_message; + notify(msg); + } else { + notify(data.error || 'Failed to add domain.', 'error'); + } + }, function() { + $scope.addDomainLoading = false; + notify('Failed to add domain.', 'error'); + }); + }; + + $scope.verifyDomain = function(domain) { + apiCall('/emailDelivery/domains/verify/', {domain: domain}, function(data) { + if (data.success) { + $scope.refreshDashboard(); + notify('Domain verification completed.'); + } else { + notify(data.error || 'Verification failed.', 'error'); + } + }); + }; + + $scope.configureDns = function(domain) { + apiCall('/emailDelivery/domains/auto-configure-dns/', {domain: domain}, function(data) { + if (data.success) { + $scope.refreshDashboard(); + notify(data.message || 'DNS configured.'); + } else { + notify(data.message || data.error || 'DNS configuration failed.', 'error'); + } + }); + }; + + $scope.removeDomain = function(domain) { + if (!confirm('Remove domain ' + domain + ' from CyberMail?')) return; + apiCall('/emailDelivery/domains/remove/', {domain: domain}, function(data) { + if (data.success) { + $scope.refreshDashboard(); + notify('Domain removed.'); + } else { + notify(data.error || 'Failed to remove domain.', 'error'); + } + }); + }; + + // ============ SMTP Credentials ============ + $scope.switchTab = function(tab) { + $scope.activeTab = tab; + if (tab === 'smtp') $scope.loadSmtpCredentials(); + if (tab === 'logs') $scope.loadLogs(); + if (tab === 'stats') $scope.loadStats(); + }; + + $scope.loadSmtpCredentials = function() { + apiCall('/emailDelivery/smtp/list/', {}, function(data) { + if (data.success) { + $scope.smtpCredentials = data.data ? data.data.credentials || [] : []; + } + }); + }; + + $scope.createSmtpCredential = function() { + $scope.createSmtpLoading = true; + apiCall('/emailDelivery/smtp/create/', { + description: $scope.newSmtpDescription + }, function(data) { + $scope.createSmtpLoading = false; + if (data.success) { + $('#createSmtpModal').modal('hide'); + $scope.newSmtpDescription = ''; + $scope.oneTimePassword = data.data ? data.data.password : null; + $scope.loadSmtpCredentials(); + if ($scope.oneTimePassword) { + $timeout(function() { $('#passwordModal').modal('show'); }, 500); + } + notify('SMTP credential created.'); + } else { + notify(data.error || 'Failed to create credential.', 'error'); + } + }, function() { + $scope.createSmtpLoading = false; + notify('Failed to create credential.', 'error'); + }); + }; + + $scope.rotatePassword = function(credId) { + if (!confirm("Rotate this credential's password? The old password will stop working immediately.")) return; + apiCall('/emailDelivery/smtp/rotate/', {credential_id: credId}, function(data) { + if (data.success) { + $scope.oneTimePassword = data.data ? data.data.password : null; + if ($scope.oneTimePassword) { + $timeout(function() { $('#passwordModal').modal('show'); }, 300); + } + notify('Password rotated.'); + } else { + notify(data.error || 'Failed to rotate password.', 'error'); + } + }); + }; + + $scope.deleteCredential = function(credId) { + if (!confirm('Delete this SMTP credential? This cannot be undone.')) return; + apiCall('/emailDelivery/smtp/delete/', {credential_id: credId}, function(data) { + if (data.success) { + $scope.loadSmtpCredentials(); + notify('Credential deleted.'); + } else { + notify(data.error || 'Failed to delete credential.', 'error'); + } + }); + }; + + // ============ Relay ============ + $scope.enableRelay = function() { + $scope.relayLoading = true; + apiCall('/emailDelivery/relay/enable/', {}, function(data) { + $scope.relayLoading = false; + if (data.success) { + $scope.account.relay_enabled = true; + notify('SMTP relay enabled!'); + } else { + notify(data.error || 'Failed to enable relay.', 'error'); + } + }, function() { + $scope.relayLoading = false; + notify('Failed to enable relay.', 'error'); + }); + }; + + $scope.disableRelay = function() { + if (!confirm('Disable SMTP relay? Postfix will send directly again.')) return; + $scope.relayLoading = true; + apiCall('/emailDelivery/relay/disable/', {}, function(data) { + $scope.relayLoading = false; + if (data.success) { + $scope.account.relay_enabled = false; + notify('SMTP relay disabled.'); + } else { + notify(data.error || 'Failed to disable relay.', 'error'); + } + }, function() { + $scope.relayLoading = false; + notify('Failed to disable relay.', 'error'); + }); + }; + + // ============ Logs ============ + $scope.loadLogs = function() { + $scope.logsLoading = true; + apiCall('/emailDelivery/logs/', { + page: $scope.logsPage, + status: $scope.logFilters.status, + from_domain: $scope.logFilters.from_domain, + days: $scope.logFilters.days || 7 + }, function(data) { + $scope.logsLoading = false; + if (data.success) { + $scope.logs = data.data ? data.data.logs || [] : []; + $scope.logsTotalPages = data.data ? data.data.total_pages || 1 : 1; + } + }, function() { + $scope.logsLoading = false; + }); + }; + + // ============ Stats ============ + $scope.loadStats = function() { + apiCall('/emailDelivery/stats/', {}, function(data) { + if (data.success) { + $scope.detailedStats = data.data || {}; + } + }); + $scope.domainStatsLoading = true; + apiCall('/emailDelivery/stats/domains/', {}, function(data) { + $scope.domainStatsLoading = false; + if (data.success) { + $scope.domainStats = data.data ? data.data.domains || [] : []; + } + }, function() { + $scope.domainStatsLoading = false; + }); + }; + + // ============ Disconnect ============ + $scope.disconnectAccount = function() { + $scope.disconnectLoading = true; + apiCall('/emailDelivery/disconnect/', {}, function(data) { + $scope.disconnectLoading = false; + if (data.success) { + $('#disconnectModal').modal('hide'); + notify('Disconnected from CyberMail.'); + $timeout(function() { window.location.reload(); }, 1000); + } else { + notify(data.error || 'Failed to disconnect.', 'error'); + } + }, function() { + $scope.disconnectLoading = false; + notify('Failed to disconnect.', 'error'); + }); + }; + +}]); diff --git a/emailDelivery/templates/emailDelivery/index.html b/emailDelivery/templates/emailDelivery/index.html new file mode 100644 index 000000000..a2244266c --- /dev/null +++ b/emailDelivery/templates/emailDelivery/index.html @@ -0,0 +1,1095 @@ +{% extends "baseTemplate/index.html" %} +{% load i18n %} +{% load static %} +{% block title %}Email Delivery - CyberPanel{% endblock %} + +{% block content %} + + + +
+ +{% if not isConnected %} + + + +
+
Free 15,000 Emails Every Month
+

Stop Landing
in Spam

+

Route your emails through dedicated infrastructure with automatic DNS setup, real-time analytics, and 98%+ inbox delivery rates.

+ +
+ + +
+
4
Delivery Nodes
+
99.9%
Uptime SLA
+
15K
Free Emails/Mo
+
98%+
Inbox Rate
+
+ + +
+ +

Up and Running in Minutes

+

Three simple steps to professional email delivery

+
+
+
+
1
+

Connect Account

+

Create your free CyberMail account with one click. No credit card required.

+
+
+
2
+

Add Your Domains

+

Select domains and we auto-configure SPF, DKIM, and DMARC records via PowerDNS.

+
+
+
3
+

Start Sending

+

Enable SMTP relay with one click. All outgoing mail routes through our optimized servers.

+
+
+ + +
+ +

Simple, Transparent Plans

+

Start free, upgrade when you need more

+
+
+
+
Free
+
$0/mo
+
15,000 emails/month
+
    +
  • Auto DNS Configuration
  • +
  • Real-time Analytics
  • +
  • SMTP Relay
  • +
  • Community Support
  • +
+ +
+
+
Starter
+
$15/mo
+
100,000 emails/month
+
    +
  • Everything in Free
  • +
  • Priority Delivery
  • +
  • Advanced Analytics
  • +
  • Email Support
  • +
+ +
+ +
+
Enterprise
+
$299/mo
+
2,000,000 emails/month
+
    +
  • Everything in Pro
  • +
  • Dedicated Infrastructure
  • +
  • Custom DKIM Signing
  • +
  • 24/7 Phone Support
  • +
+ +
+
+ + +
+ +

Built for Deliverability

+

Everything you need for reliable email delivery

+
+
+
+
+

Dedicated Infrastructure

+

Custom-built email servers optimized for maximum inbox placement rates.

+
+
+
+

Auto DNS Setup

+

One-click SPF, DKIM, and DMARC configuration through PowerDNS integration.

+
+
+
+

Real-time Analytics

+

Track delivery rates, bounces, opens, and engagement metrics live.

+
+
+
+

Multi-Region Delivery

+

Send from US and EU regions for optimal delivery speeds worldwide.

+
+
+
+

Reputation Management

+

Automatic IP warm-up and monitoring to protect your sender score.

+
+
+
+

One-Click Relay

+

Configure Postfix SMTP relay instantly. No code changes needed.

+
+
+ + +
+ +{% endif %} + +{% if isConnected %} + + + +
+
+

Loading dashboard...

+
+ +
+ + +
+
+
Email Delivery
+
+ Connected + {$ account.plan_name $} Plan +
+
+
+ Docs + + +
+
+ + +
+
+
Monthly Usage
+
{$ stats.emails_sent || 0 $} / {$ account.emails_per_month $}
+
+
+
+
+
+
{$ getUsagePercent() $}% used
+
+
+ + +
+
+ Approaching your plan limit +

Upgrade to Starter for 100K emails/month at just $15/mo

+
+ Upgrade Plan +
+ + +
+ + + + + +
+ + +
+
+
+

Sending Domains

+ +
+
+ + No domains added yet. Add a domain to start sending emails through CyberMail. +
+ + + + + + + + + + + + + +
DomainStatusSPFDKIMDMARCDNSActions
{$ d.domain $}{$ d.status $}{$ d.spf_verified ? 'OK' : 'Missing' $}{$ d.dkim_verified ? 'OK' : 'Missing' $}{$ d.dmarc_verified ? 'OK' : 'Missing' $}{$ d.dns_configured ? 'Auto' : 'Manual' $} + + + +
+
+
+ + +
+
+
+

SMTP Credentials

+ +
+
+ + No SMTP credentials yet. Create one to use for relay or external applications. +
+ + + + + + + + + + +
UsernameDescriptionCreatedActions
{$ cred.username $}{$ cred.description $}{$ cred.created_at $} + + +
+
+
+ + +
+
+
+

SMTP Relay Configuration

+
+

+ Route all outgoing Postfix mail through CyberMail's optimized delivery infrastructure. +

+
+
+
+ {$ account.relay_enabled ? 'Relay Active' : 'Relay Disabled' $} + Routing via {$ account.smtp_host $}:{$ account.smtp_port $} + Postfix is sending directly +
+ + +
+
+ + How it works: Enabling relay configures Postfix to send all outbound mail through CyberMail. An SMTP credential is created automatically. +
+
+
+ + +
+
+
+

Delivery Logs

+
+
+ + +
+
+
+ No delivery logs found for this period. +
+ + + + + + + + + + + +
DateFromToSubjectStatus
{$ log.date | limitTo:19 $}{$ log.from $}{$ log.to $}{$ log.subject $} + {$ log.status $} +
+
+ + Page {$ logsPage $} of {$ logsTotalPages $} + +
+
+
+ + +
+
+
{$ detailedStats.total_sent || 0 $}
Total Sent
+
{$ detailedStats.delivered || 0 $}
Delivered
+
{$ detailedStats.bounced || 0 $}
Bounced
+
{$ detailedStats.failed || 0 $}
Failed
+
{$ (detailedStats.delivery_rate || 0) + '%' $}
Delivery Rate
+
+
+

Per-Domain Breakdown

+
+ + + + + + + + + +
DomainSentDeliveredBouncedFailedRate
{$ ds.domain $}{$ ds.sent $}{$ ds.delivered $}{$ ds.bounced $}{$ ds.failed $}{$ ds.delivery_rate $}%
+
+ No domain stats available yet. Send some emails first. +
+
+
+ +
+{% endif %} + +
+ + + + + + + + + + + + + + + + + + + + +{% endblock %} diff --git a/emailDelivery/urls.py b/emailDelivery/urls.py new file mode 100644 index 000000000..d47b6ba13 --- /dev/null +++ b/emailDelivery/urls.py @@ -0,0 +1,30 @@ +from django.urls import path +from . import views + +urlpatterns = [ + path('', views.index, name='emailDeliveryHome'), + path('connect/', views.connect, name='emailDeliveryConnect'), + path('status/', views.getStatus, name='emailDeliveryStatus'), + path('disconnect/', views.disconnect, name='emailDeliveryDisconnect'), + # Domains + path('domains/add/', views.addDomain, name='emailDeliveryAddDomain'), + path('domains/list/', views.listDomains, name='emailDeliveryListDomains'), + path('domains/verify/', views.verifyDomain, name='emailDeliveryVerifyDomain'), + path('domains/dns-records/', views.getDnsRecords, name='emailDeliveryDnsRecords'), + path('domains/auto-configure-dns/', views.autoConfigureDns, name='emailDeliveryAutoConfigureDns'), + path('domains/remove/', views.removeDomain, name='emailDeliveryRemoveDomain'), + # SMTP Credentials + path('smtp/create/', views.createSmtpCredential, name='emailDeliverySmtpCreate'), + path('smtp/list/', views.listSmtpCredentials, name='emailDeliverySmtpList'), + path('smtp/rotate/', views.rotateSmtpPassword, name='emailDeliverySmtpRotate'), + path('smtp/delete/', views.deleteSmtpCredential, name='emailDeliverySmtpDelete'), + # Relay + path('relay/enable/', views.enableRelay, name='emailDeliveryRelayEnable'), + path('relay/disable/', views.disableRelay, name='emailDeliveryRelayDisable'), + # Stats & Logs + path('stats/', views.getStats, name='emailDeliveryStats'), + path('stats/domains/', views.getDomainStats, name='emailDeliveryDomainStats'), + path('logs/', views.getLogs, name='emailDeliveryLogs'), + # Health + path('health/', views.checkStatus, name='emailDeliveryHealth'), +] diff --git a/emailDelivery/views.py b/emailDelivery/views.py new file mode 100644 index 000000000..6c8bef0c1 --- /dev/null +++ b/emailDelivery/views.py @@ -0,0 +1,184 @@ +from django.shortcuts import redirect +from django.http import JsonResponse +from loginSystem.views import loadLoginPage +from .emailDeliveryManager import EmailDeliveryManager + + +def index(request): + try: + userID = request.session['userID'] + em = EmailDeliveryManager() + return em.home(request, userID) + except KeyError: + return redirect(loadLoginPage) + + +def connect(request): + try: + userID = request.session['userID'] + em = EmailDeliveryManager() + return em.connect(request, userID) + except KeyError: + return JsonResponse({'success': False, 'error': 'Not authenticated'}) + + +def getStatus(request): + try: + userID = request.session['userID'] + em = EmailDeliveryManager() + return em.getStatus(request, userID) + except KeyError: + return JsonResponse({'success': False, 'error': 'Not authenticated'}) + + +def disconnect(request): + try: + userID = request.session['userID'] + em = EmailDeliveryManager() + return em.disconnect(request, userID) + except KeyError: + return JsonResponse({'success': False, 'error': 'Not authenticated'}) + + +def addDomain(request): + try: + userID = request.session['userID'] + em = EmailDeliveryManager() + return em.addDomain(request, userID) + except KeyError: + return JsonResponse({'success': False, 'error': 'Not authenticated'}) + + +def listDomains(request): + try: + userID = request.session['userID'] + em = EmailDeliveryManager() + return em.listDomains(request, userID) + except KeyError: + return JsonResponse({'success': False, 'error': 'Not authenticated'}) + + +def verifyDomain(request): + try: + userID = request.session['userID'] + em = EmailDeliveryManager() + return em.verifyDomain(request, userID) + except KeyError: + return JsonResponse({'success': False, 'error': 'Not authenticated'}) + + +def getDnsRecords(request): + try: + userID = request.session['userID'] + em = EmailDeliveryManager() + return em.getDnsRecords(request, userID) + except KeyError: + return JsonResponse({'success': False, 'error': 'Not authenticated'}) + + +def autoConfigureDns(request): + try: + userID = request.session['userID'] + em = EmailDeliveryManager() + return em.autoConfigureDns(request, userID) + except KeyError: + return JsonResponse({'success': False, 'error': 'Not authenticated'}) + + +def removeDomain(request): + try: + userID = request.session['userID'] + em = EmailDeliveryManager() + return em.removeDomain(request, userID) + except KeyError: + return JsonResponse({'success': False, 'error': 'Not authenticated'}) + + +def createSmtpCredential(request): + try: + userID = request.session['userID'] + em = EmailDeliveryManager() + return em.createSmtpCredential(request, userID) + except KeyError: + return JsonResponse({'success': False, 'error': 'Not authenticated'}) + + +def listSmtpCredentials(request): + try: + userID = request.session['userID'] + em = EmailDeliveryManager() + return em.listSmtpCredentials(request, userID) + except KeyError: + return JsonResponse({'success': False, 'error': 'Not authenticated'}) + + +def rotateSmtpPassword(request): + try: + userID = request.session['userID'] + em = EmailDeliveryManager() + return em.rotateSmtpPassword(request, userID) + except KeyError: + return JsonResponse({'success': False, 'error': 'Not authenticated'}) + + +def deleteSmtpCredential(request): + try: + userID = request.session['userID'] + em = EmailDeliveryManager() + return em.deleteSmtpCredential(request, userID) + except KeyError: + return JsonResponse({'success': False, 'error': 'Not authenticated'}) + + +def enableRelay(request): + try: + userID = request.session['userID'] + em = EmailDeliveryManager() + return em.enableRelay(request, userID) + except KeyError: + return JsonResponse({'success': False, 'error': 'Not authenticated'}) + + +def disableRelay(request): + try: + userID = request.session['userID'] + em = EmailDeliveryManager() + return em.disableRelay(request, userID) + except KeyError: + return JsonResponse({'success': False, 'error': 'Not authenticated'}) + + +def getStats(request): + try: + userID = request.session['userID'] + em = EmailDeliveryManager() + return em.getStats(request, userID) + except KeyError: + return JsonResponse({'success': False, 'error': 'Not authenticated'}) + + +def getDomainStats(request): + try: + userID = request.session['userID'] + em = EmailDeliveryManager() + return em.getDomainStats(request, userID) + except KeyError: + return JsonResponse({'success': False, 'error': 'Not authenticated'}) + + +def getLogs(request): + try: + userID = request.session['userID'] + em = EmailDeliveryManager() + return em.getLogs(request, userID) + except KeyError: + return JsonResponse({'success': False, 'error': 'Not authenticated'}) + + +def checkStatus(request): + try: + userID = request.session['userID'] + em = EmailDeliveryManager() + return em.checkStatus(request, userID) + except KeyError: + return JsonResponse({'success': False, 'error': 'Not authenticated'}) diff --git a/emailMarketing/templates/emailMarketing/emailMarketing.html b/emailMarketing/templates/emailMarketing/emailMarketing.html index e2ba1970b..f1ef45a84 100644 --- a/emailMarketing/templates/emailMarketing/emailMarketing.html +++ b/emailMarketing/templates/emailMarketing/emailMarketing.html @@ -7,6 +7,21 @@ {% get_current_language as LANGUAGE_CODE %} + +
diff --git a/emailPremium/templates/emailPremium/emailPage.html b/emailPremium/templates/emailPremium/emailPage.html index e91aba9a4..410efb15f 100644 --- a/emailPremium/templates/emailPremium/emailPage.html +++ b/emailPremium/templates/emailPremium/emailPage.html @@ -7,6 +7,22 @@ {% get_current_language as LANGUAGE_CODE %} + + +
diff --git a/filemanager/views.py b/filemanager/views.py index d7749ab64..14c75c648 100644 --- a/filemanager/views.py +++ b/filemanager/views.py @@ -307,13 +307,19 @@ def downloadFile(request): try: userID = request.session['userID'] admin = Administrator.objects.get(pk=userID) - from urllib.parse import quote - from django.utils.encoding import iri_to_uri + from urllib.parse import unquote - fileToDownload = request.build_absolute_uri().split('fileToDownload')[1][1:] - fileToDownload = iri_to_uri(fileToDownload) + # Properly get fileToDownload from query parameters + fileToDownload = request.GET.get('fileToDownload') + if not fileToDownload: + return HttpResponse("Unauthorized access: Not a valid file.") + + # URL decode the file path + fileToDownload = unquote(fileToDownload) domainName = request.GET.get('domainName') + if not domainName: + return HttpResponse("Unauthorized access: Domain not specified.") currentACL = ACLManager.loadedACL(userID) @@ -324,8 +330,14 @@ def downloadFile(request): homePath = '/home/%s' % (domainName) - if fileToDownload.find('..') > -1 or fileToDownload.find(homePath) == -1: - return HttpResponse("Unauthorized access.") + # Security checks: prevent directory traversal and ensure file is within domain's home path + if '..' in fileToDownload or not fileToDownload.startswith(homePath): + return HttpResponse("Unauthorized access: Not a valid file.") + + # Normalize path to prevent any path traversal attempts + fileToDownload = os.path.normpath(fileToDownload) + if not fileToDownload.startswith(homePath): + return HttpResponse("Unauthorized access: Not a valid file.") # SECURITY: Check for symlink attacks - resolve the real path and verify it stays within homePath try: @@ -356,11 +368,15 @@ def downloadFile(request): def RootDownloadFile(request): try: userID = request.session['userID'] - from urllib.parse import quote - from django.utils.encoding import iri_to_uri + from urllib.parse import unquote - fileToDownload = request.build_absolute_uri().split('fileToDownload')[1][1:] - fileToDownload = iri_to_uri(fileToDownload) + # Properly get fileToDownload from query parameters + fileToDownload = request.GET.get('fileToDownload') + if not fileToDownload: + return HttpResponse("Unauthorized access: Not a valid file.") + + # URL decode the file path + fileToDownload = unquote(fileToDownload) currentACL = ACLManager.loadedACL(userID) @@ -370,9 +386,12 @@ def RootDownloadFile(request): return ACLManager.loadError() # SECURITY: Prevent path traversal attacks - if fileToDownload.find('..') > -1: + if '..' in fileToDownload: return HttpResponse("Unauthorized access: Path traversal detected.") + # Normalize path to prevent any path traversal attempts + fileToDownload = os.path.normpath(fileToDownload) + # SECURITY: Check for symlink attacks - resolve the real path and verify it's safe try: # Get the real path (resolves symlinks) diff --git a/fix_cyberpanel_install.sh b/fix_cyberpanel_install.sh index d8fe8c196..3d7afbeb4 100644 --- a/fix_cyberpanel_install.sh +++ b/fix_cyberpanel_install.sh @@ -58,9 +58,9 @@ else # Detect OS version and download appropriate requirements if grep -q "22.04" /etc/os-release || grep -q "VERSION_ID=\"9" /etc/os-release; then - wget -q -O /tmp/requirements.txt https://raw.githubusercontent.com/usmannasir/cyberpanel/v2.4.4-dev/requirments.txt + wget -q -O /tmp/requirements.txt https://raw.githubusercontent.com/usmannasir/cyberpanel/v2.4.5/requirments.txt else - wget -q -O /tmp/requirements.txt https://raw.githubusercontent.com/usmannasir/cyberpanel/v2.4.4-dev/requirments-old.txt + wget -q -O /tmp/requirements.txt https://raw.githubusercontent.com/usmannasir/cyberpanel/v2.4.5/requirments-old.txt fi # Upgrade pip first diff --git a/install/email-configs-one/dovecot-sql.conf.ext b/install/email-configs-one/dovecot-sql.conf.ext index 0fb900ce3..2dbf65a24 100644 --- a/install/email-configs-one/dovecot-sql.conf.ext +++ b/install/email-configs-one/dovecot-sql.conf.ext @@ -1,4 +1,4 @@ driver = mysql connect = host=localhost dbname=cyberpanel user=cyberpanel password=SLTUIUxqhulwsh port=3306 password_query = SELECT email as user, password FROM e_users WHERE email='%u'; -user_query = SELECT '5000' as uid, '5000' as gid, mail FROM e_users WHERE email='%u'; \ No newline at end of file +user_query = SELECT '5000' as uid, '5000' as gid, mail, CONCAT('/home/vmail/', SUBSTRING_INDEX(email, '@', -1), '/', SUBSTRING_INDEX(email, '@', 1)) as home FROM e_users WHERE email='%u'; \ No newline at end of file diff --git a/install/email-configs-one/dovecot.conf b/install/email-configs-one/dovecot.conf index 9ec4b1a1c..097805e33 100644 --- a/install/email-configs-one/dovecot.conf +++ b/install/email-configs-one/dovecot.conf @@ -1,4 +1,4 @@ -protocols = imap pop3 +protocols = imap pop3 sieve log_timestamp = "%Y-%m-%d %H:%M:%S " #mail_location = maildir:/home/vmail/%d/%n/Maildir #mail_location = mdbox:/home/vmail/%d/%n/Mdbox @@ -41,7 +41,9 @@ protocol lda { auth_socket_path = /var/run/dovecot/auth-master postmaster_address = postmaster@example.com - mail_plugins = zlib + mail_plugins = zlib sieve + lda_mailbox_autocreate = yes + lda_mailbox_autosubscribe = yes } protocol pop3 { @@ -53,6 +55,15 @@ protocol imap { mail_plugins = $mail_plugins zlib imap_zlib } +auth_master_user_separator = * + +passdb { + driver = passwd-file + master = yes + args = /etc/dovecot/master-users + result_success = continue +} + passdb { driver = sql args = /etc/dovecot/dovecot-sql.conf.ext @@ -68,6 +79,9 @@ plugin { zlib_save = gz zlib_save_level = 6 + sieve = ~/sieve/.dovecot.sieve + sieve_dir = ~/sieve + } service stats { diff --git a/install/email-configs/dovecot-sql.conf.ext b/install/email-configs/dovecot-sql.conf.ext index 4fd7025fa..f453b1e5a 100644 --- a/install/email-configs/dovecot-sql.conf.ext +++ b/install/email-configs/dovecot-sql.conf.ext @@ -1,4 +1,4 @@ driver = mysql connect = host=127.0.0.1 dbname=cyberpanel user=cyberpanel password=1qaz@9xvps password_query = SELECT email as user, password FROM e_users WHERE email='%u'; -user_query = SELECT '5000' as uid, '5000' as gid, mail FROM e_users WHERE email='%u'; +user_query = SELECT '5000' as uid, '5000' as gid, mail, CONCAT('/home/vmail/', SUBSTRING_INDEX(email, '@', -1), '/', SUBSTRING_INDEX(email, '@', 1)) as home FROM e_users WHERE email='%u'; diff --git a/install/email-configs/dovecot.conf b/install/email-configs/dovecot.conf index a7694b6ed..c5f87f5ef 100644 --- a/install/email-configs/dovecot.conf +++ b/install/email-configs/dovecot.conf @@ -1,4 +1,4 @@ -protocols = imap pop3 +protocols = imap pop3 sieve log_timestamp = "%Y-%m-%d %H:%M:%S " #mail_location = maildir:/home/vmail/%d/%n/Maildir #mail_location = mdbox:/home/vmail/%d/%n/Mdbox @@ -41,7 +41,9 @@ protocol lda { auth_socket_path = /var/run/dovecot/auth-master postmaster_address = postmaster@example.com - mail_plugins = zlib + mail_plugins = zlib sieve + lda_mailbox_autocreate = yes + lda_mailbox_autosubscribe = yes } protocol pop3 { @@ -53,6 +55,15 @@ protocol imap { mail_plugins = $mail_plugins zlib imap_zlib } +auth_master_user_separator = * + +passdb { + driver = passwd-file + master = yes + args = /etc/dovecot/master-users + result_success = continue +} + passdb { driver = sql args = /etc/dovecot/dovecot-sql.conf.ext @@ -69,6 +80,9 @@ plugin { zlib_save = gz zlib_save_level = 6 + sieve = ~/sieve/.dovecot.sieve + sieve_dir = ~/sieve + } service stats { diff --git a/install/install.py b/install/install.py index 46ae8b05a..294f979dd 100644 --- a/install/install.py +++ b/install/install.py @@ -1018,10 +1018,10 @@ $cfg['Servers'][$i]['LogoutURL'] = 'phpmyadminsignin.php?logout'; command = 'dnf --nogpg install -y https://mirror.ghettoforge.net/distributions/gf/gf-release-latest.gf.el8.noarch.rpm' preFlightsChecks.call(command, self.distro, command, command, 1, 0, os.EX_OSERR) - command = 'dnf install --enablerepo=gf-plus postfix3 postfix3-mysql -y' + command = 'dnf install --enablerepo=gf-plus postfix3 postfix3-mysql cyrus-sasl-plain -y' preFlightsChecks.call(command, self.distro, command, command, 1, 1, os.EX_OSERR) elif self.distro == openeuler: - command = 'dnf install postfix -y' + command = 'dnf install postfix cyrus-sasl-plain -y' preFlightsChecks.call(command, self.distro, command, command, 1, 1, os.EX_OSERR) else: @@ -1044,9 +1044,15 @@ $cfg['Servers'][$i]['LogoutURL'] = 'phpmyadminsignin.php?logout'; if self.distro == centos: command = 'yum --enablerepo=gf-plus -y install dovecot23 dovecot23-mysql' elif self.distro == cent8: - command = 'dnf install --enablerepo=gf-plus dovecot23 dovecot23-mysql -y' + clAPVersion = FetchCloudLinuxAlmaVersionVersion() + type = clAPVersion.split('-')[0] + version = int(clAPVersion.split('-')[1]) + if type == 'al' and version >= 90: + command = 'dnf install -y dovecot dovecot-mysql' + else: + command = 'dnf install --enablerepo=gf-plus dovecot23 dovecot23-mysql -y' elif self.distro == openeuler: - command = 'dnf install dovecot -y' + command = 'dnf install -y dovecot dovecot-mysql' else: command = 'DEBIAN_FRONTEND=noninteractive apt-get -y install dovecot-mysql dovecot-imapd dovecot-pop3d' @@ -1396,6 +1402,16 @@ $cfg['Servers'][$i]['LogoutURL'] = 'phpmyadminsignin.php?logout'; command = "mkdir -p /etc/pki/dovecot/certs/" preFlightsChecks.call(command, self.distro, command, command, 1, 0, os.EX_OSERR) + # Copy self-signed certs to where Postfix main.cf expects them + command = "cp /etc/dovecot/cert.pem /etc/pki/dovecot/certs/dovecot.pem" + preFlightsChecks.call(command, self.distro, command, command, 1, 0, os.EX_OSERR) + + command = "cp /etc/dovecot/key.pem /etc/pki/dovecot/private/dovecot.pem" + preFlightsChecks.call(command, self.distro, command, command, 1, 0, os.EX_OSERR) + + command = "chmod 600 /etc/pki/dovecot/private/dovecot.pem" + preFlightsChecks.call(command, self.distro, command, command, 1, 0, os.EX_OSERR) + command = "mkdir -p /etc/opendkim/keys/" preFlightsChecks.call(command, self.distro, command, command, 1, 0, os.EX_OSERR) @@ -2880,11 +2896,13 @@ def main(): checks.install_postfix_dovecot() checks.setup_email_Passwords(installCyberPanel.InstallCyberPanel.mysqlPassword, mysql) checks.setup_postfix_dovecot_config(mysql) + installCyberPanel.InstallCyberPanel.setupWebmail() else: if args.postfix == 'ON': checks.install_postfix_dovecot() checks.setup_email_Passwords(installCyberPanel.InstallCyberPanel.mysqlPassword, mysql) checks.setup_postfix_dovecot_config(mysql) + installCyberPanel.InstallCyberPanel.setupWebmail() checks.install_unzip() checks.install_zip() diff --git a/install/installCyberPanel.py b/install/installCyberPanel.py index ab825f79b..93f5f6cd2 100644 --- a/install/installCyberPanel.py +++ b/install/installCyberPanel.py @@ -328,24 +328,25 @@ class InstallCyberPanel: # Platform-specific URLs and checksums (OpenLiteSpeed v2.4.4 โ€” all features config-driven, static linking) # Includes: PHPConfig API, Origin Header Forwarding, ReadApacheConf (with Portmap), Auto-SSL (ACME v2), ModSecurity ABI Compatibility + # Module rebuilt 2026-03-04: fix SIGSEGV crash in apply_headers() on error responses (4xx/5xx) BINARY_CONFIGS = { 'rhel8': { 'url': 'https://cyberpanel.net/openlitespeed-2.4.4-x86_64-rhel8', - 'sha256': '70002c488309c9ed650f3de2959bcf4db847b8204f6fe242e523523b621fd316', + 'sha256': 'd08512da7a77468c09d6161de858db60bcc29aed7ce0abf76dca1c72104dc485', 'module_url': 'https://cyberpanel.net/cyberpanel_ols-2.4.4-x86_64-rhel8.so', - 'module_sha256': '27f7fbbb74e83c217708960d4b18e2732b0798beecba8ed6eac01509165cb432' + 'module_sha256': '3fd3bf6e2d50fe2e94e67fcf9f8ee24c4cc31b9edb641bee8c129cb316c3454a' }, 'rhel9': { 'url': 'https://cyberpanel.net/openlitespeed-2.4.4-x86_64-rhel9', - 'sha256': '4fed6d0c70b23ebb73efc6f17f2f2bb2afc84b23b36c02308b8b2fefc56a291c', + 'sha256': '418d2ea06e29c0f847a2e6cf01f7641d5fb72b65a04e27a8f6b3b54d673cc2df', 'module_url': 'https://cyberpanel.net/cyberpanel_ols-2.4.4-x86_64-rhel9.so', - 'module_sha256': '50cb00fa2b8269ec9b0bf300f1b26d3b76d3791c1b022343e1290a0d25e7fda8' + 'module_sha256': '4863fc4c227e50e2d6ec5827aed3e1ad92e9be03a548b7aa1a8a4640853db399' }, 'ubuntu': { 'url': 'https://cyberpanel.net/openlitespeed-2.4.4-x86_64-ubuntu', - 'sha256': '004b69dcc7daf21412ddbdfff5fd4e191293035a8f7c5e7cffd7be7ada070445', + 'sha256': '60edf815379c32705540ad4525ea6d07c0390cabca232b6be12376ee538f4b1b', 'module_url': 'https://cyberpanel.net/cyberpanel_ols-2.4.4-x86_64-ubuntu.so', - 'module_sha256': 'bd47069d13bb098201f3e72d4d56876193c898ebfa0ac2eb26796abebc991a88' + 'module_sha256': '0d7dd17c6e64ac46d4abd5ccb67cc2da51809e24692774e4df76d8f3a6c67e9d' } } @@ -499,6 +500,26 @@ module cyberpanel_ols { # Configure the custom module self.configureCustomModule() + # Enable Auto-SSL in httpd_config.conf + try: + import re + conf_path = '/usr/local/lsws/conf/httpd_config.conf' + if os.path.exists(conf_path): + with open(conf_path, 'r') as f: + content = f.read() + if 'autoSSL' not in content: + content = re.sub( + r'(adminEmails\s+\S+)', + r'\1\nautoSSL 1\nacmeEmail admin@cyberpanel.net', + content, + count=1 + ) + with open(conf_path, 'w') as f: + f.write(content) + InstallCyberPanel.stdOut("Auto-SSL enabled in httpd_config.conf", 1) + except Exception as e: + InstallCyberPanel.stdOut(f"WARNING: Could not enable Auto-SSL: {e}", 1) + else: try: try: @@ -665,25 +686,137 @@ module cyberpanel_ols { """Install Sieve (Dovecot Sieve) for email filtering on all OS variants""" try: InstallCyberPanel.stdOut("Installing Sieve (Dovecot Sieve) for email filtering...", 1) - + if self.distro == ubuntu: # Install dovecot-sieve and dovecot-managesieved self.install_package('dovecot-sieve dovecot-managesieved') else: # For CentOS/AlmaLinux/OpenEuler self.install_package('dovecot-pigeonhole') - + + # Write ManageSieve config + managesieve_conf = '/etc/dovecot/conf.d/20-managesieve.conf' + os.makedirs('/etc/dovecot/conf.d', exist_ok=True) + with open(managesieve_conf, 'w') as f: + f.write("""protocols = $protocols sieve + +service managesieve-login { + inet_listener sieve { + port = 4190 + } +} + +service managesieve { + process_limit = 256 +} + +protocol sieve { + managesieve_notify_capability = mailto + managesieve_sieve_capability = fileinto reject envelope encoded-character vacation subaddress comparator-i;ascii-numeric relational regex imap4flags copy include variables body enotify environment mailbox date index ihave duplicate mime foreverypart extracttext +} +""") + # Add Sieve port 4190 to firewall - from plogical.firewallUtilities import FirewallUtilities - FirewallUtilities.addSieveFirewallRule() - + try: + import firewall.core.fw as fw + subprocess.call(['firewall-cmd', '--permanent', '--add-port=4190/tcp'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + subprocess.call(['firewall-cmd', '--reload'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + except Exception: + # firewalld may not be available, try ufw + subprocess.call(['ufw', 'allow', '4190/tcp'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + InstallCyberPanel.stdOut("Sieve successfully installed and configured!", 1) return 1 - + except BaseException as msg: logging.InstallLog.writeToFile('[ERROR] ' + str(msg) + " [installSieve]") return 0 + @staticmethod + def setupWebmail(): + """Set up Dovecot master user and webmail config for SSO""" + try: + # Skip if dovecot not installed + if not os.path.exists('/etc/dovecot/dovecot.conf'): + InstallCyberPanel.stdOut("Dovecot not installed, skipping webmail setup.", 1) + return 1 + + # Skip if already configured + if os.path.exists('/etc/cyberpanel/webmail.conf') and os.path.exists('/etc/dovecot/master-users'): + InstallCyberPanel.stdOut("Webmail master user already configured.", 1) + return 1 + + InstallCyberPanel.stdOut("Setting up webmail master user for SSO...", 1) + + import secrets, string + chars = string.ascii_letters + string.digits + master_password = ''.join(secrets.choice(chars) for _ in range(32)) + + # Hash the password using doveadm + result = subprocess.run( + ['doveadm', 'pw', '-s', 'SHA512-CRYPT', '-p', master_password], + capture_output=True, text=True + ) + if result.returncode != 0: + logging.InstallLog.writeToFile('[ERROR] doveadm pw failed: ' + result.stderr + " [setupWebmail]") + return 0 + + password_hash = result.stdout.strip() + + # Write /etc/dovecot/master-users + with open('/etc/dovecot/master-users', 'w') as f: + f.write('cyberpanel_master:' + password_hash + '\n') + os.chmod('/etc/dovecot/master-users', 0o600) + subprocess.call(['chown', 'dovecot:dovecot', '/etc/dovecot/master-users']) + + # Ensure /etc/cyberpanel/ exists + os.makedirs('/etc/cyberpanel', exist_ok=True) + + # Write /etc/cyberpanel/webmail.conf + import json as json_module + webmail_conf = { + 'master_user': 'cyberpanel_master', + 'master_password': master_password + } + with open('/etc/cyberpanel/webmail.conf', 'w') as f: + json_module.dump(webmail_conf, f) + os.chmod('/etc/cyberpanel/webmail.conf', 0o600) + subprocess.call(['chown', 'cyberpanel:cyberpanel', '/etc/cyberpanel/webmail.conf']) + + # Patch dovecot.conf if master passdb block missing + dovecot_conf_path = '/etc/dovecot/dovecot.conf' + with open(dovecot_conf_path, 'r') as f: + dovecot_content = f.read() + + if 'auth_master_user_separator' not in dovecot_content: + master_block = """auth_master_user_separator = * + +passdb { + driver = passwd-file + master = yes + args = /etc/dovecot/master-users + result_success = continue +} + +""" + dovecot_content = dovecot_content.replace( + 'passdb {', + master_block + 'passdb {', + 1 + ) + with open(dovecot_conf_path, 'w') as f: + f.write(dovecot_content) + + # Restart Dovecot to pick up changes + subprocess.call(['systemctl', 'restart', 'dovecot']) + + InstallCyberPanel.stdOut("Webmail master user setup complete!", 1) + return 1 + + except BaseException as msg: + logging.InstallLog.writeToFile('[ERROR] ' + str(msg) + " [setupWebmail]") + return 0 + def installMySQL(self, mysql): ############## Install mariadb ###################### @@ -1299,6 +1432,8 @@ def Main(cwd, mysql, distro, ent, serial=None, port="8090", ftp=None, dns=None, logging.InstallLog.writeToFile('Installing Sieve for email filtering..,55') installer.installSieve() + ## setupWebmail is called later, after Dovecot is installed (see install.py) + logging.InstallLog.writeToFile('Installing MySQL,60') installer.installMySQL(mysql) installer.changeMYSQLRootPassword() diff --git a/install/litespeed/conf/httpd_config.conf b/install/litespeed/conf/httpd_config.conf index 8b65fc2d1..4ea95bb5c 100644 --- a/install/litespeed/conf/httpd_config.conf +++ b/install/litespeed/conf/httpd_config.conf @@ -12,6 +12,8 @@ gracefulRestartTimeout 300 mime $SERVER_ROOT/conf/mime.properties showVersionNumber 0 adminEmails root@localhost +autoSSL 1 +acmeEmail admin@cyberpanel.net adminRoot $SERVER_ROOT/admin/ errorlog $SERVER_ROOT/logs/error.log { diff --git a/mailServer/templates/mailServer/createEmailAccount.html b/mailServer/templates/mailServer/createEmailAccount.html index 5f381679d..b08abba4d 100644 --- a/mailServer/templates/mailServer/createEmailAccount.html +++ b/mailServer/templates/mailServer/createEmailAccount.html @@ -317,6 +317,22 @@ } + + +