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 #
- Confirm Ready: Status → Pending, builds subscriber list
- Calculate Statistics: Count subscribers
- Queue Processing: Create send jobs
- 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!"