Troubleshooting

Common errors with actionable fixes. Sorted by surface area: install, mail, billing, deliverability, jobs.

Install + first run

500 error on /install

"Class not found" after upload

composer dump-autoload --optimize
php artisan optimize:clear

Vite asset URLs 404

Mail not sending

Test send reports "OK" but inbox stays empty

  1. Open Admin → Notifications → Logs → find the row → click the eye icon.
  2. Check the Gateway column: which mailer was used, what host, what message-id.
  3. "Sent" only means the SMTP relay accepted the message — it doesn't guarantee inbox delivery.
  4. Cross-reference the message-id with your provider's outbound log (Hostinger / SES / SendGrid dashboard).
  5. 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"

"Could not connect" / connection timeout

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

Customer still on old plan after upgrade

"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

"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:

Background jobs

Campaigns stuck "Sending"

Scheduled tasks not firing

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>.

  1. 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.
  2. Apache buyers — the shipped .htaccess already maps /storage/(.*)storage/app/public/$1. Verify it's there:
    grep "storage/app/public" .htaccess
    If empty, the file is stale — re-upload the .htaccess from the release ZIP.
  3. nginx buyers — your vhost needs this block (then nginx -s reload):
    location ^~ /storage/ {
        alias /var/www/kodmail/storage/app/public/;
        access_log off;
        expires 7d;
    }
    The full template lives at nginx.conf.example in the project root.
  4. If you still see 403, the public-disk symlink needs refreshing:
    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"
    Or with Laravel ≥ 10: php artisan storage:link --force.
  5. "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.
  6. Symlinks blocked on shared host? No problem — the .htaccess rewrite (Apache) or nginx alias directive (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:

  1. Confirm the file actually exists on disk: ls -la build/assets/ public/build/assets/ — at least one should list the file.
  2. nginx vhost has try_files $uri $uri/ /index.php?$query_string; inside location /.
  3. For shared-host nginx auto-vhosts, paste the full block from nginx.conf.example.

Page renders blank / "ReferenceError"

OAuth callback fails

Blank pages after an in-place upgrade

Almost always PHP OPcache holding the pre-upgrade compiled classes. Fix from your hosting panel:

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

PathContents
storage/logs/laravel.logApp errors, warnings, notif dispatcher breadcrumbs.
storage/logs/worker.logQueue worker output (when Supervisor / systemd configured).
/var/log/nginx/error.log or /var/log/apache2/error.logServer-side PHP errors before Laravel kicks in.
Stripe Dashboard → Developers → Webhooks → Recent eventsInbound webhook history + delivery status.