Skip to content
prod e051e98
Browse

4 · Email infrastructure

Objective — confirm and test the transactional mail path, choose a provider from the deliverability matrix, customize the core template set, and (optionally) add drip campaigns and a newsletter — all with SPF/DKIM/DMARC passing — so launch-critical mail reliably reaches the inbox.

Transactional email is launch-blocking: password resets, receipts, and verification mails must arrive in the inbox — not spam. This page is the canonical place to wire email end-to-end. DNS records (SPF/DKIM/DMARC) and the MAIL_* env vars are set in Phase 4; here you confirm, choose a provider, and prove delivery.

flowchart LR
A["1. Confirm + test<br/>existing config"] --> B{"Inbox + passing<br/>auth?"}
B -- Yes --> D["3. Templates"]
B -- "No / spam" --> C["2. Provider setup"]
C --> D
D --> E["4. Drip campaigns<br/>(optional)"]
E --> F["5. Newsletter<br/>(optional)"]

1. Confirm the provider & test delivery (MUST)

Section titled “1. Confirm the provider & test delivery (MUST)”

Most CodeCanyon apps ship with email already wired — verify what’s in place before adding a new provider.

  1. Check the existing config.

    Terminal window
    grep -E "^MAIL_MAILER|^MAIL_HOST|^MAIL_PORT|^MAIL_FROM" Admin-Local/1-Project/2-Env/.env.production 2>/dev/null
    # Expected: MAIL_HOST set and MAIL_MAILER not log/array → email is configured

    If MAIL_HOST is set and MAIL_MAILER isn’t log/array, email is configured — go to step 3.

    • ✅ The MAIL_* config is read and its state known.
  2. Check the admin-panel override. Many apps store mail settings in the DB, overriding .env (Settings → SMTP, Config → Mail Configuration, System → Mail Settings). Confirm which layer wins.

    Terminal window
    php artisan tinker
    >>> config('mail.mailers.smtp.host')
    # Expected: the effective SMTP host (DB-override or .env)
    • ✅ You know whether the admin panel or .env wins at runtime.
  3. Send a real test, then verify it in the inbox.

    Terminal window
    php artisan tinker
    >>> Mail::raw('Test on '.now(), fn($m) => $m->to('you@<domain>')->subject('Mail test'));
    # Expected: the test mail is dispatched without error

    Then check the inbox (including spam), open the email source and confirm SPF, DKIM, and DMARC all show pass, and trigger a real transactional mail via /password/reset.

    • ✅ The test email arrives in the inbox with spf=pass/dkim=pass/dmarc=pass, and password reset works end-to-end.
  4. If delivery fails or lands in spam — set up a dedicated provider (section 2). Shared-hosting SMTP works for low volume but deliverability degrades fast past ~50/day.

    • ✅ A decision is made: keep the existing config, or move to section 2.

2. Set up a provider (MUST if delivery is weak)

Section titled “2. Set up a provider (MUST if delivery is weak)”

Pick one transactional provider, authenticate the domain, then re-run the section-1 test.

  1. Pick one transactional provider.

    ProviderFree tierDeliverabilityBest forRegion
    SendGrid100/day forever99.95%Default choice, best docs, native Laravel driverGlobal, US
    Mailgun5k/mo for 3 mo, then PAYG99.9%Pay-per-email, developer-focusedUS + EU
    AWS SES62k/mo free from EC2, else $0.10/1k99%Highest volume, lowest cost at scaleMulti-region
    Brevo300/day forever98%Newsletter + transactional in oneEU, GDPR-friendly
    Hostinger TitanIncluded with hosting95%Low volume, already on Hostinger~50/day practical
    Resend3k/mo, 100/day99%Modern API + DX, React EmailUS, newer

    Decision criteria: starting out / global → SendGrid; EU-first / GDPR → Mailgun (EU) or Brevo; heavy on AWS, >10k/day → SES; already on Hostinger, <50/day → Titan; modern stack, love DX → Resend.

    • ✅ One provider is chosen against the criteria.
  2. Wire SendGrid (recommended default). Create the account at sendgrid.com, authenticate the domain (Settings → Sender Authentication → Authenticate Your Domain → add the 3 DKIM CNAMEs to DNS per Phase 4, then Verify), create a Restricted — Mail Send only API key (<project>-production, shown once), then wire the env vars.

    Terminal window
    MAIL_MAILER=smtp
    MAIL_HOST=smtp.sendgrid.net
    MAIL_PORT=587
    MAIL_USERNAME=apikey
    MAIL_PASSWORD=<SG.your-api-key>
    MAIL_ENCRYPTION=tls
    MAIL_FROM_ADDRESS=noreply@<domain>
    MAIL_FROM_NAME="<App Name>"
    # Expected: domain shows green DKIM checks; env wired with the API key from the vault
    • ✅ The domain is authenticated and the SendGrid env vars are wired.
  3. Re-run the section-1 test with the new provider wired.

    • ✅ The test send succeeds with spf=pass, dkim=pass, dmarc=pass.

If you chose a provider other than SendGrid, the domain-auth + key pattern is the same — only the specific records and env keys differ.

3. Customize the transactional templates (SHOULD)

Section titled “3. Customize the transactional templates (SHOULD)”

Customize the core template set so every system email is on-brand.

  1. Check the admin panel. Many apps ship a template editor (Settings → Email Templates, Communication → Templates, Admin → Notifications → Email). Customize subjects, body, colors, and logo in the UI.

    • ✅ Templates are customized in the admin UI (if it exists).
  2. Check DB seeders. Some apps store templates in an email_templates table.

    Terminal window
    ls database/seeders/ | grep -i -E "email|template|mail"
    php artisan db:seed --class=EmailTemplatesSeeder --force # after editing
    # Expected: a template seeder is found and reseeded
    • ✅ Template seeders are found and reseeded (if present).
  3. Use code-based Markdown mail (no admin UI, no seeder).

    Terminal window
    php artisan vendor:publish --tag=laravel-mail # edit resources/views/vendor/mail/html/*.blade.php
    php artisan vendor:publish --tag=cashier-views # edit resources/views/vendor/cashier/*.blade.php
    # Expected: the mail + cashier views are published for editing
    • ✅ The Markdown mail views are published for editing.
  4. Customize the minimum template set — always customize these.

    TemplateTriggerCritical fields
    WelcomeRegistrationLogo, first-step CTA, support link
    Email verificationVerify notificationSigned URL that expires
    Password reset/password/resetSigned URL, 60-min expiry notice
    Invoice receiptinvoice.payment_succeededAmount, plan, PDF link
    Renewal reminderCashier 3-day-before hookRenewal date, cancel link
    Payment failedinvoice.payment_failedRetry link, grace period, support
    • ✅ All six core templates are customized.
  5. Apply brand consistency — logo via an absolute HTTPS URL (not asset()), brand-colored primary button, footer with company name + physical address (CAN-SPAM) + unsubscribe link for marketing mail, <App Name> subject prefix where appropriate.

    • ✅ Triggering all six templates in staging renders correctly in Gmail, Outlook, and an iOS client, matching the brand kit.

4. Add email sequences / drip campaigns (OPTIONAL)

Section titled “4. Add email sequences / drip campaigns (OPTIONAL)”

Wire automated sequences if the product benefits from onboarding/trial nudges.

  1. Check the admin panel for a drip UI (Automation → Sequences, Marketing → Email Sequences). If present, configure day 0/3/7/14 emails there.

    • ✅ Drip sequences are configured in the admin UI (if it exists).
  2. Choose an approach.

    ApproachCostBest for
    Laravel-native (Notification + scheduler)$0Simple sequences; teams already in Laravel
    Mailchimp AutomationsFree ≤500Non-technical, visual editor, high volume
    ConvertKit Sequences$0–15/moCreators, course sales, opt-in funnels
    Encharge / Customer.io$49+/moProduct-led growth, complex branching

    Default to Laravel-native for transactional sequences (welcome, trial-ending, re-engagement); graduate to ConvertKit/Mailchimp when marketing outgrows developer capacity.

    • ✅ A drip approach is chosen.
  3. Wire the Laravel-native example (if chosen).

    app/Console/Commands/SendTrialEndingDrip.php
    $users = User::whereHas('subscription', fn($q) => $q->where('trial_ends_at', '<=', now()->addDays(3)))
    ->whereDoesntHave('notifications', fn($q) => $q->where('type', TrialEndingNotification::class))
    ->get();
    foreach ($users as $user) { $user->notify(new TrialEndingNotification()); }

    Schedule it in app/Console/Kernel.php: $schedule->command('drips:trial-ending')->dailyAt('10:00');

    • ✅ The drip command is wired and scheduled.
  4. Wire the external handoff (if using ConvertKit/Mailchimp/Encharge) — tag users from your webhook handlers (sign up, upgrade, cancel, churn) so the external tool drives the drip.

    • ✅ The first email sends manually, and users exit the sequence when they take the intended action (e.g. upgrade → skip remaining trial-ending mails).

Wire subscriber capture if you’ll publish to users.

  1. Check the admin panel for subscriber management (Marketing → Subscribers, Communication → Mailing List).

    • ✅ The admin newsletter surface is used if it exists.
  2. Choose a platform.

    PlatformCostDouble opt-inBest for
    Laravel-native (spatie/laravel-newsletter + Mailchimp)Free + Mailchimp freeYesSelf-hosted signup forms
    Mailchimp embedFree ≤500YesFastest setup, zero code
    ConvertKit embed$0–15/moYesCreators, editorial content
    BeehiivFree ≤2,500YesPublication-style, monetization
    • ✅ A newsletter platform is chosen.
  3. Wire the spatie/laravel-newsletter approach (if chosen).

    Terminal window
    composer require spatie/laravel-newsletter
    php artisan vendor:publish --provider="Spatie\Newsletter\NewsletterServiceProvider"
    # Expected: the package installs and its config is published
    public function subscribe(Request $request): RedirectResponse
    {
    $request->validate(['email' => 'required|email']);
    Newsletter::subscribe($request->email);
    return back()->with('status', 'Subscribed! Check your inbox to confirm.');
    }
    • ✅ The subscribe endpoint is wired.
  4. Apply compliance — double opt-in (GDPR/CAN-SPAM/CASL), unsubscribe link in every email, sender physical address in the footer, an explicit (not pre-checked) consent box, and a mention in the Phase 7 privacy policy’s “marketing communications” section.

    • ✅ Signup creates a subscriber in the platform dashboard, the confirmation email arrives with a working opt-in link, and the unsubscribe link actually unsubscribes.

Do not mark this step done until every box below is checked.

  • 🔀 Test mail lands in the inbox — with spf=pass, dkim=pass, dmarc=pass.
  • 👤 A dedicated provider is wired — if shared SMTP deliverability was weak.
  • 🔀 The six core templates render — correctly in Gmail, Outlook, and iOS, on-brand.
  • 🔀 Drip campaigns and newsletter configured — or consciously deferred, not silently skipped.