Messages #

Complete guide to creating, managing, and sending email campaigns.

Overview #

Messages are the core of the campaign system:

  • Campaign Creation: Design and compose email content
  • Lifecycle Management: Draft → Pending → Sending → Sent
  • Multi-tenant: Organization-scoped with data isolation
  • Status Tracking: Monitor delivery, opens, clicks, unsubscribes
  • Template Integration: Use dynamic content with merge tags

Database Structure #

Messages stored in campaign_messages table:

Schema::create('campaign_messages', function($table) {
    $table->increments('id');
    $table->integer('organization_id')->unsigned();
    $table->integer('sender_profile_id')->unsigned()->nullable();
    $table->integer('status_id')->unsigned();
    $table->string('name'); // Internal campaign name
    $table->string('subject'); // Email subject line
    $table->text('preheader')->nullable(); // Preview text
    $table->longText('content_html'); // Email HTML
    $table->longText('content_text')->nullable(); // Plain text version
    $table->boolean('is_dynamic_template')->default(false);
    $table->boolean('is_full_width')->default(false);
    $table->string('header_image')->nullable();
    $table->string('footer_image')->nullable();

    // Send configuration
    $table->boolean('is_delayed')->default(false);
    $table->timestamp('launch_at')->nullable();
    $table->string('launch_timezone')->nullable();
    $table->boolean('is_staggered')->default(false);
    $table->string('stagger_type')->nullable(); // time, count
    $table->integer('stagger_time')->nullable(); // hours
    $table->integer('stagger_count')->nullable(); // per 15min

    // Statistics
    $table->integer('count_subscriber')->default(0);
    $table->integer('count_sent')->default(0);
    $table->integer('count_delivered')->default(0);
    $table->integer('count_bounced')->default(0);
    $table->integer('count_read')->default(0);
    $table->integer('count_clicked')->default(0);
    $table->integer('count_stop')->default(0);
    $table->integer('count_failed')->default(0);

    $table->timestamp('processed_at')->nullable();
    $table->timestamps();
});

Model Relationships #

class Message extends Model
{
    public $belongsTo = [
        'organization' => Organization::class,
        'senderProfile' => SenderProfile::class,
        'status' => MessageStatus::class
    ];

    public $hasOne = [
        'schedule' => MessageSchedule::class
    ];

    public $belongsToMany = [
        'subscriberLists' => [
            SubscriberList::class,
            'table' => 'campaign_messages_subscriber_lists'
        ]
    ];
}

Message Lifecycle #

Status Flow #

Draft → Pending → Processing → Active → Sent
                              ↓
                          Cancelled

Status Definitions #

// MessageStatus constants
const STATUS_DRAFT = 1;      // Being edited
const STATUS_PENDING = 2;    // Queued to send
const STATUS_PROCESSING = 3; // Building recipient list
const STATUS_ACTIVE = 4;     // Currently sending
const STATUS_SENT = 5;       // Completed
const STATUS_CANCELLED = 6;  // Stopped before completion
const STATUS_SCHEDULED = 7;  // Scheduled/recurring

Creating Messages #

Form Structure #

The message editor is organized into three main sections:

Configuration

  • Campaign name (internal reference)
  • Email subject line
  • Sender profile selection

Content

  • Preheader text (email preview)
  • Content body (rich text editor with drag-and-drop merge tags)
  • Disclaimer (optional footer text)

Layout

  • Full-width email option
  • Header image settings (show/hide, width, alignment)
  • Address footer display

Basic Message #

use Albrightlabs\Campaign\Models\Message;

$message = Message::create([
    'organization_id' => $org->id,
    'sender_profile_id' => $profile->id,
    'status_id' => MessageStatus::STATUS_DRAFT,
    'name' => 'February Newsletter',
    'subject' => 'Your February Update',
    'preheader' => 'Latest news and updates inside',
    'content_html' => '<h1>Hello {first_name}!</h1><p>Welcome...</p>',
    'disclaimer' => 'Optional disclaimer text here',
    'is_dynamic_template' => false,
    'is_full_width' => false
]);

With Subscriber Lists #

$message->subscriberLists()->attach([1, 2, 3]);

// Or during creation
$message->subscriberLists = [1, 2, 3];

With Schedule #

$schedule = MessageSchedule::create([
    'message_id' => $message->id,
    'organization_id' => $org->id,
    'frequency' => 'weekly',
    'send_time' => '09:00',
    'timezone' => 'America/New_York',
    'day_of_week' => 1
]);

Dynamic Content Tags #

Available Merge Tags #

// Subscriber tags
{first_name}      // Subscriber first name
{last_name}       // Subscriber last name
{email}           // Subscriber email
{full_name}       // Full name

// Conditional tags
{first_name|there}  // "John" or "there" if empty

// Organization tags
{organization_name}
{organization_address}
{organization_phone}

// Campaign tags
{unsubscribe_url}  // Required
{view_online_url}  // Optional
{preference_url}   // Manage preferences

Tag Processing #

use Albrightlabs\Campaign\Classes\TagManager;

$processed = TagManager::instance()->processTags(
    $message->content_html,
    $subscriber,
    $message
);

Send Configuration #

Immediate Send #

$message->is_delayed = false;
$message->save();

// Launch immediately
CampaignManager::instance()->confirmReady($message);

Scheduled Send #

$message->is_delayed = true;
$message->launch_at = '2024-02-14 10:00:00';
$message->launch_timezone = 'America/New_York';
$message->save();

Staggered Delivery #

// Spread over time
$message->is_staggered = true;
$message->stagger_type = 'time';
$message->stagger_time = 24; // hours
$message->save();

// Or send in batches
$message->is_staggered = true;
$message->stagger_type = 'count';
$message->stagger_count = 100; // per 15 minutes
$message->save();

Launching Campaigns #

Via CampaignManager #

use Albrightlabs\Campaign\Classes\CampaignManager;

$manager = CampaignManager::instance();

// Mark as ready to send
$manager->confirmReady($message);

// Actually launch the campaign
$manager->launchCampaign($message);

Launch Process #

  1. Confirm Ready: Status → Pending, builds subscriber list
  2. Calculate Statistics: Count subscribers
  3. Queue Processing: Create send jobs
  4. Update Status: Status → Processing → Active → Sent

Statistics & Analytics #

Rebuilding Stats #

$message->rebuildStats();

// Recalculates:
// - count_subscriber
// - count_sent
// - count_delivered
// - count_bounced
// - count_read
// - count_clicked
// - count_stop
// - count_failed

Calculating Rates #

// Open rate accessor
public function getOpenRateAttribute()
{
    if ($this->count_sent == 0) return 0;
    return round(($this->count_read / $this->count_sent) * 100, 2);
}

// Click rate accessor
public function getClickRateAttribute()
{
    if ($this->count_sent == 0) return 0;
    return round(($this->count_clicked / $this->count_sent) * 100, 2);
}

// Unsubscribe rate accessor
public function getUnsubscribeRateAttribute()
{
    if ($this->count_sent == 0) return 0;
    return round(($this->count_stop / $this->count_sent) * 100, 2);
}

Usage #

echo "Open Rate: {$message->open_rate}%";
echo "Click Rate: {$message->click_rate}%";
echo "Unsubscribe Rate: {$message->unsubscribe_rate}%";

Testing Messages #

Send Test Email #

// Via controller action
public function onSendTestMessage()
{
    $recipientEmail = post('recipient_email');
    $subscriberId = post('subscriber_id'); // Optional preview subscriber

    $message = $this->formGetModel();

    // Send test
    $this->widget->previewSelector->sendTestMessage(
        $message,
        $recipientEmail,
        $subscriberId
    );

    Flash::success('Test message sent!');
}

Test with Preview Subscriber #

// Use actual subscriber data for merge tags
$subscriber = Subscriber::find($subscriberId);
$content = TagManager::instance()->processTags(
    $message->content_html,
    $subscriber,
    $message
);

Message Duplication #

Duplicate Campaign #

$newMessage = $message->replicate();
$newMessage->name = $message->name . ' (Copy)';
$newMessage->status_id = MessageStatus::STATUS_DRAFT;
$newMessage->created_at = now();
$newMessage->save();

// Copy relationships
$newMessage->subscriberLists()->sync(
    $message->subscriberLists->pluck('id')
);

Content Locking #

Once sent, content is locked:

public function beforeUpdate()
{
    // Prevent editing sent campaigns
    if ($this->status->code == 'sent') {
        if ($this->isDirty('content_html') || $this->isDirty('subject')) {
            throw new ValidationException([
                'content_html' => 'Cannot edit sent campaigns'
            ]);
        }
    }
}

Template Features #

Dynamic Templates #

$message->is_dynamic_template = true;

Dynamic templates personalize content per subscriber:

  • Merge tags processed individually
  • Conditional content blocks
  • Custom data from subscriber metadata

Full-Width Layout #

$message->is_full_width = true;

Changes email template:

  • Removes side padding
  • Extends content to email width
  • Better for hero images

Header/Footer Images #

$message->header_image = 'path/to/header.jpg';
$message->footer_image = 'path/to/footer.jpg';

Validation Rules #

public $rules = [
    'organization_id' => 'required|exists:organizations,id',
    'sender_profile_id' => 'nullable|exists:sender_profiles,id',
    'name' => 'required|min:2|max:255',
    'subject' => 'required|min:2|max:255',
    'content_html' => 'required',
    'launch_at' => 'required_if:is_delayed,true',
    'stagger_time' => 'required_if:stagger_type,time',
    'stagger_count' => 'required_if:stagger_type,count'
];

Query Scopes #

By Status #

// Get drafts
$drafts = Message::whereHas('status', function($q) {
    $q->where('code', 'draft');
})->get();

// Get sent messages
$sent = Message::whereHas('status', function($q) {
    $q->where('code', 'sent');
})->get();

Organization Scope #

// Automatically scoped via SaasBase
Message::all(); // Only current organization's messages

Recent Messages #

$recent = Message::orderBy('created_at', 'desc')
    ->limit(10)
    ->get();

Best Practices #

Content Guidelines #

  • Subject lines under 50 characters
  • Preheader text 40-130 characters
  • Always include {unsubscribe_url}
  • Test on multiple email clients
  • Optimize images for web

Performance #

  • Limit inline images
  • Use external image hosting
  • Keep HTML under 100KB
  • Minimize CSS complexity
  • Test load times

Deliverability #

  • Authenticate sender domain (SPF/DKIM)
  • Avoid spam trigger words
  • Maintain text-to-image ratio
  • Include physical address
  • Provide easy unsubscribe

Troubleshooting #

Message Stuck in Processing #

// Check status
$message = Message::find($id);
echo "Status: " . $message->status->name;
echo "Processed at: " . $message->processed_at;

// Reset if stuck
$message->status_id = MessageStatus::STATUS_DRAFT;
$message->save();

Statistics Not Updating #

// Rebuild from actual data
$message->rebuildStats();

// Check webhook configuration
// Verify tracking pixel in HTML

Tags Not Rendering #

// Test tag processing
$test = TagManager::instance()->processTags(
    'Hello {first_name}!',
    $subscriber,
    $message
);
echo $test; // Should show "Hello John!"

← Sender Profiles | Next: Lists →