Troubleshooting
Common errors with actionable fixes. Sorted by surface area: install, mail, billing, deliverability, jobs.
Install + first run
500 error on /install
- Check
storage/logs/laravel.log. storage/+bootstrap/cache/must be 775 owned by web user..envexists and hasAPP_KEYset (php artisan key:generate).
"Class not found" after upload
composer dump-autoload --optimize
php artisan optimize:clear
Vite asset URLs 404
- Run
npm install && npm run buildbefore going live. public/build/directory must exist with hashed JS / CSS files.APP_URLin .env must match the served URL exactly (scheme + host + port).
Mail not sending
Test send reports "OK" but inbox stays empty
- Open Admin → Notifications → Logs → find the row → click the eye icon.
- Check the Gateway column: which mailer was used, what host, what message-id.
- "Sent" only means the SMTP relay accepted the message — it doesn't guarantee inbox delivery.
- Cross-reference the message-id with your provider's outbound log (Hostinger / SES / SendGrid dashboard).
- Check the recipient's spam folder. New domains hit spam ~80% of the time until DKIM + DMARC are clean.
"554 5.7.1 Disabled by user from hPanel" (Hostinger)
Your Hostinger mailbox is disabled at the provider level — usually after exceeding daily send quota or abuse flag. Login to Hostinger control panel → Emails → re-enable the mailbox or wait for daily reset.
"535 5.7.8 Authentication failed"
- Wrong username / password.
- Gmail / Outlook: you must use an App Password, not your normal account password.
- Two-factor enabled on the mailbox? You need the App Password.
"Could not connect" / connection timeout
- Wrong host or port.
- Server firewall blocks outbound port (very common on shared hosting). Open the port or use a different relay (HTTP API drivers like SES / SendGrid bypass SMTP port restrictions).
TLS / certificate errors
Disable "Verify TLS certificate" on the mailer — many shared hosts ship self-signed or mismatched certs. KodMail defaults this OFF for that reason.
Gmail filter drops mail with naked-IP URLs
If your APP_URL is a raw IP (e.g. http://1.2.3.4:8081/path), Gmail aggressively spam-folders any URL in the body. Fix: point a real domain at the server + set APP_URL=https://app.example.com. Also publish SPF / DKIM / DMARC for your sending domain.
Billing
Stripe webhook returns 403
- CSRF blocking the endpoint.
bootstrap/app.phpmust have'stripe/webhook'in thevalidateCsrfTokens(except:)list. Ships exempt by default.
Customer still on old plan after upgrade
- Multiple lingering Stripe subscriptions. Cashier returns the first (oldest) — clean up at the Stripe Dashboard.
- Manual subscription not deleted before Stripe upgrade. KodMail's manual→Stripe swap deletes the manual row, but if the swap was interrupted, run
php artisan billing:reconcile.
"No subscription" after manual approve
Manual approve reuses existing row with synthetic manual_sub_<id> stripe-id. If reconcile is hitting Stripe with that id, it'll come back as incomplete_expired. KodMail's reconcile auto-skips manual stripe-ids — clear caches with php artisan optimize:clear if behaviour persists.
Deliverability
DKIM check failing
- DNS hasn't propagated yet (give it 1-4 hours).
- TXT record value mangled by DNS host's UI (split across lines / extra quotes). Use
dig +short TXT selector._domainkey.example.comto confirm exact value. - Selector mismatch — KodMail's default selector is
kodmail. Change in the sending-domain settings if you used a different name.
"DKIM did not pass" at Gmail
Mail leaves KodMail signed, but a relay in between (e.g. mail forwarder, customer's own SMTP smarthost) strips the signature. Two fixes:
- Use a direct SMTP / API relay that signs on your behalf (SendGrid, Postmark, Mailgun all sign).
- Add a tracking/sending domain pointing directly at the recipient MX.
Background jobs
Campaigns stuck "Sending"
- Queue worker isn't running. Restart Supervisor / systemd / cron.
php artisan queue:work --once --queue=campaignsmanually to drain.php artisan campaigns:reconcileheals stuck statuses.
Scheduled tasks not firing
- Cron entry missing.
crontab -lon the server. - Cron user can't write to
storage/logs/. Ensurewww-dataowns it. php artisan schedule:runmanually — does it print "No scheduled commands ready to run"? Then the schedule isn't reaching its window.
UI / asset issues
Uploaded images / assets don't display (broken thumbnails)
Files exist on disk under storage/app/public/assets/... but the browser shows
broken-image placeholders in the Admin → Assets library. Asset URLs return 404 because the
webserver isn't routing /storage/<path> to storage/app/public/<path>.
- Open one asset URL directly in the browser:
https://yourdomain.com/storage/assets/2026/06/sample.png.- 404 → webserver rewrite missing (fix below).
- 403 → symlink permission issue; see step 4.
- Loads → DB has wrong URL; run
php artisan cache:clear.
- Apache buyers — the shipped
.htaccessalready maps/storage/(.*)→storage/app/public/$1. Verify it's there:
If empty, the file is stale — re-upload thegrep "storage/app/public" .htaccess.htaccessfrom the release ZIP. - nginx buyers — your vhost needs this block (then
nginx -s reload):
The full template lives atlocation ^~ /storage/ { alias /var/www/kodmail/storage/app/public/; access_log off; expires 7d; }nginx.conf.examplein the project root. - If you still see 403, the public-disk symlink needs refreshing:
Or with Laravel ≥ 10:cd /path/to/kodmail ls -la public/storage # check current state rm -rf public/storage php artisan storage:link ls -la public/storage # should print "lrwxrwxrwx ... -> ../storage/app/public"php artisan storage:link --force. - "The [public/storage] link already exists" — that's the file blocking the
new symlink. Remove it and rerun:
rm -rf public/storage && php artisan storage:link. - Symlinks blocked on shared host? No problem — the
.htaccessrewrite (Apache) or nginxaliasdirective (above) serves files without needing the symlink. Just make sure one of those two paths is in place.
Build assets (JS / CSS) 404 on first install
URL like https://yourdomain.com/build/assets/app-XXXX.js returns 404. Same root
cause as uploaded assets — webserver isn't routing the path. Apache handles it via
.htaccess. nginx needs the standard try_files directive, plus
KodMail's release ZIP mirrors build/ to the project root so any vhost setup
serves the files. If 404 persists:
- Confirm the file actually exists on disk:
ls -la build/assets/ public/build/assets/— at least one should list the file. - nginx vhost has
try_files $uri $uri/ /index.php?$query_string;insidelocation /. - For shared-host nginx auto-vhosts, paste the full block from
nginx.conf.example.
Page renders blank / "ReferenceError"
- Open browser devtools → Console.
- Run
npm run buildafter every server-side schema change. php artisan optimize:clearto flush stale view / route caches.
OAuth callback fails
- Callback URL whitelisted in Google Cloud Console exactly matches the one shown in Admin → Settings → Auth.
APP_URLin .env must be HTTPS.sessiondriver is reachable (file driver: storage/framework/sessions/ writable; database driver:sessionstable exists).
Blank pages after an in-place upgrade
Almost always PHP OPcache holding the pre-upgrade compiled classes. Fix from your hosting panel:
- cPanel / Plesk / DirectAdmin — open PHP Selector or MultiPHP and toggle the domain's PHP version off then back on (or restart PHP-FPM).
- VPS with systemd —
sudo systemctl restart php-fpm(adjust for your PHP major version, e.g.php8.2-fpm). - Laragon / XAMPP — click Reload in the tray icon, not Stop → Start.
Then hard-refresh the browser (Ctrl+F5).
"Forbidden" or 419 on plan / template / settings save
Cookie or CSRF token failing to bind to the domain. Make sure your .env has both:
APP_URL=https://your-exact-domain.com
SESSION_DOMAIN=your-exact-domain.com # no protocol, no path
Then run php artisan config:clear, log out, and sign back in.
If the request is still rejected with a plain "Forbidden" HTML page, your host's WAF (mod_security on cPanel-style shared hosting) may be blocking PUT/DELETE requests. Contact your host and ask them to whitelist /admin/* for your account, or switch to a rule set that allows the Laravel verbs.
Tracking domain shows CNAME to "localhost"
Fixed in v1.1.0 — the setup instructions now use the current request host as a fallback when APP_URL is blank. If you're on v1.0.0, set APP_URL=https://your-panel-domain.com in .env, then run php artisan config:clear and reload the tracking page.
Custom SMTP port (e.g. 6868) resets to 25 / 465 / 587
Fixed in v1.1.0. In earlier builds, changing the Encryption select auto-fills the port to the standard for that encryption, overwriting any custom value. From v1.1.0 the auto-fill only applies when the port field is empty or still on a stock default. Custom ports survive encryption changes.
All ports 1–65535 are accepted by the backend, and any encryption + port combination works — including port 587 with encryption set to none, if your server actually accepts that.
"System customer not found" when clicking Marketing Workspace
Fixed in v1.1.0 — the installer now auto-seeds the required row, and the controller lazy-creates it as a fallback for older installs. If you're stuck on v1.0.0 and can't upgrade yet, run this one-time command from the project root:
php artisan db:seed --class=SystemCustomerSeeder --force
Installer wizard shows raw translation keys (e.g. installer.welcome.heading)
Fixed in v1.1.0. The bug was that the translation catalog cache short-circuited when the database wasn't fully migrated yet, and the installer's Inertia share dropped the catalog entirely. Both paths are patched — re-download the ZIP and run through the wizard again on a fresh directory.
Log files to check
| Path | Contents |
|---|---|
storage/logs/laravel.log | App errors, warnings, notif dispatcher breadcrumbs. |
storage/logs/worker.log | Queue worker output (when Supervisor / systemd configured). |
/var/log/nginx/error.log or /var/log/apache2/error.log | Server-side PHP errors before Laravel kicks in. |
| Stripe Dashboard → Developers → Webhooks → Recent events | Inbound webhook history + delivery status. |