Loading...

This is taking longer than expected.

Back to the help centre

Email and SMS delivery

The mail transports the kit knows, the keys each one reads, and the two providers that can carry a text message.

This guide covers how an email leaves the application and how a text message does: which transport is used, which environment variables each one reads, what happens when a transport fails, and how to see mail while you develop. You need it before the first person registers, because the verification code travels on these settings.

Which mailer sends

config/mail.php reads MAIL_MAILER to pick the default mailer, and .env.example ships MAIL_MAILER=failover. Every mailer the file defines is listed below; the value you write in MAIL_MAILER is the name in the first column.

Mailer What it does What it reads
smtp Talks to an SMTP server MAIL_HOST (default 127.0.0.1), MAIL_PORT (default 2525), MAIL_USERNAME, MAIL_PASSWORD; optionally MAIL_SCHEME, MAIL_URL and MAIL_EHLO_DOMAIN, which defaults to the host of APP_URL
ses Amazon SES through the AWS SDK AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_DEFAULT_REGION (falls back to us-east-1 for SES)
mailgun The Mailgun API MAILGUN_DOMAIN, MAILGUN_SECRET, MAILGUN_ENDPOINT (default api.mailgun.net; use api.eu.mailgun.net for an EU domain)
postmark The Postmark API POSTMARK_TOKEN, optionally POSTMARK_MESSAGE_STREAM_ID; the client gives up after 5 seconds
resend The Resend API RESEND_KEY
sendmail The local sendmail binary MAIL_SENDMAIL_PATH (default /usr/sbin/sendmail -bs -i)
log Writes the whole message to the log instead of sending it MAIL_LOG_CHANNEL; empty means the default log channel
array Keeps messages in memory; for tests Nothing
failover Tries mailgun, then ses The keys of both
roundrobin Alternates between ses and postmark The keys of both

Two of them are configured but not installed: the postmark transport needs symfony/postmark-mailer and the resend one needs resend/resend-php, and neither is in composer.json. Require the package before pointing MAIL_MAILER or a roundrobin entry at them. Mailgun (symfony/mailgun-mailer) and SES (aws/aws-sdk-php) are installed.

How failover behaves

The failover mailer sends through the first mailer of its list that accepts the message. With the shipped list, that is Mailgun; when Mailgun throws, the same message goes to SES. A mailer that failed is skipped for retry_after seconds, 60 in the file, before it is tried again. Nothing is retried later: when every mailer in the list fails, the send fails.

This is why a fresh .env sends nothing. failover needs the Mailgun and the SES keys; with neither, both fail. Pick a real transport:

MAIL_MAILER=mailgun
MAILGUN_DOMAIN=mg.your-project.test
MAILGUN_SECRET=key-xxxxxxxx

or keep failover and fill in both providers. Edit the mailers list in config/mail.php when your pair is different, for example ses then smtp.

MAILGUN_DOMAIN has a default of mg.weblabor.mx in config/services.php. That is Weblabor's own sending domain; set your own or your mail is refused by Mailgun.

The sender

MAIL_FROM_ADDRESS="[email protected]"
MAIL_FROM_NAME="${APP_NAME}"

MAIL_FROM_ADDRESS defaults to [email protected] and MAIL_FROM_NAME to APP_NAME. .env.example ships [email protected]: change it, since every provider only sends from a domain you verified with them.

The verification email and mail_fallbacks

The code a person types to verify their email is not a notification. It is the Mailable App\Mail\Auth\ValidateEmail, with the subject "Email validation: {app name}" and the Markdown view resources/views/emails/auth/validate_email.blade.php, sent by App\Livewire\Auth\Traits\NeedsVerification through the Mail facade. The code is ten random letters and digits.

That trait is the only reader of mail_fallbacks in config/auth.php:

'mail_fallbacks' => [
    'mailgun',
],

It builds a list that starts with MAIL_MAILER and continues with these names. The first send uses the first entry; every press of Resend code moves to the next one, whether the previous one failed or simply never arrived. When the list is exhausted, Resend shows "Unable to send code" and suggests another address. Once the code is confirmed, the name of the mailer that worked is stored in the user's extra_data under mail_driver_used. With the shipped values the order is failover, then mailgun on the first resend; write the transports you actually have, in the order you want them tried.

The other mail views

The kit renders three emails of its own with Laravel's Markdown mail components (<x-mail::message>, <x-mail::button>):

View Sent when
resources/views/emails/auth/validate_email.blade.php A person verifies their email
resources/views/emails/announcements/announcement.blade.php An announcement is published with "send email": title, body and a Read announcement button
resources/views/emails/user/update_role_email.blade.php An administrator adds or removes a role

Every other notification uses Laravel's default MailMessage template. To restyle them all, publish the mail components with php artisan vendor:publish --tag=laravel-mail and edit resources/views/vendor/mail.

Text messages

There is no SMS package. The kit ships one notification channel, App\Channels\SmsChannel, and one service behind it, App\Services\SnsService, which publishes through Amazon SNS.

The channel

SmsChannel::send() asks the notifiable for routeNotificationFor('sms'), falls back to its phone attribute, and calls toSms() on the notification for the text. Then SnsService::sendSms($phone, $message) publishes it. The client is built from AWS_DEFAULT_REGION (default us-west-1), AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY, the same credentials the ses mailer and the s3 disk read, and every message is sent with SMSType Transactional, the delivery class carriers prioritise. A rejected publish is reported to the log and returns false; the channel then throws, so the notification counts as failed.

Use it in a notification of your own:

<?php

namespace App\Notifications\Orders;

use App\Channels\SmsChannel;
use App\Models\Order;
use Illuminate\Notifications\Notification;

class OrderReady extends Notification
{
    public function __construct(public Order $order) {}

    public function via(object $notifiable): array
    {
        return [SmsChannel::class];
    }

    public function toSms(object $notifiable): string
    {
        return __(':app - Your order :number is ready', [
            'app' => config('app.name'),
            'number' => $this->order->number,
        ]);
    }
}

The phone must be in international format, +52 55 1234 5678 written as +525512345678. The channel sends whatever the phone column holds; the kit stores phones normalised at registration, so a phone you write yourself is your job to normalise, and normalize_phone_number($value) returns the E.164 form or null.

This channel is separate from the base App\Notifications\Notification class: a notification that extends the base goes to the database, email and web push, never to SMS, and no user preference turns SMS off. See /help/send-notifications.

Who sends the phone verification code

AUTH_VALIDATION_PROVIDER decides which provider carries the four-digit code of /help/verify-email-and-phone. User::sendVerificationCode() reads it:

Value What happens
aws (default) The user is notified with App\Notifications\Auth\VerificationCodeNotification, which goes through SmsChannel and SNS with the text "{app} - Your verification code is: {code}"
telnyx App\Services\TelnyxVerifyService::sendVerification($phone, $code, $method) calls Telnyx Verify, by sms or by call; the verification dialog offers both, Receive a call included

Telnyx reads TELNYX_API_KEY (falling back to TELNYX_TOKEN) and TELNYX_VERIFY_PROFILE_ID, the id of a Verify profile created in the Telnyx portal. The service normalises the phone with normalize_phone_number() first and returns false, without calling out, when the phone does not parse or either key is missing; the dialog then says "Unable to send code". .env.example ships AUTH_VALIDATION_PROVIDER=aws and both Telnyx keys with placeholder values.

Every send is logged

App\Listeners\LogNotificationSent and App\Listeners\LogNotificationFailed listen to Laravel's notification events and write one row to communication_logs for every notification that goes through the mail channel or SmsChannel: the user, the recipient, the notification class, the subject and the rendered HTML body of a sent email, and sent or failed. Database and web push deliveries are not logged. Two sends bypass the listeners because they are not notifications: the verification email, sent with the Mail facade, and a Telnyx code. Administrators read the table at /admin/communication_logs; see /help/audit-trails-and-webhook-events.

The Communication Logs list in the admin panel, one row per email sent

Seeing mail while you develop

Nothing in the kit fakes a provider, so use the transports made for it:

MAIL_MAILER=log

writes every message, headers and body, to the default log channel, which is storage/logs/laravel-YYYY-MM-DD.log unless LOG_CHANNEL says otherwise; set MAIL_LOG_CHANNEL to send them to a channel of their own. Or point smtp at a local catcher such as Mailpit:

MAIL_MAILER=smtp
MAIL_HOST=127.0.0.1
MAIL_PORT=1025

and read the messages in its inbox. Both work for the verification email too, since mail_fallbacks starts with MAIL_MAILER.

There is no equivalent for SMS. Without AWS or Telnyx credentials a phone code cannot be sent and the dialog says so; while you have none, register with an email or set AUTH_ENABLE_VALIDATION=false locally.