Minnow

Minnow Developer Documentation

Table of Contents


Mail System

The Minnow mail system provides a modern, extensible API for sending emails with support for templates, queueing, and hooks.

Sending Emails

Quick Send

use function Minnow\Core\Mail\minnow_mail;

// Simple usage (like wp_mail)
minnow_mail('user@example.com', 'Subject', 'Message body');

// With headers
minnow_mail(
    'user@example.com',
    'Welcome',
    '<h1>Hello!</h1>',
    ['Content-Type' => 'text/html', 'From' => 'Admin <admin@example.com>']
);

// Multiple recipients
minnow_mail(
    ['user1@example.com', 'user2@example.com'],
    'Newsletter',
    'Check out our updates!'
);

Using the Mailer Class

use Minnow\Core\Mail\Mailer;
use Minnow\Core\Mail\Message;

$message = Message::create()
    ->to('user@example.com')
    ->subject('Hello')
    ->text('Plain text body');

$sent = Mailer::send($message);

if (!$sent) {
    $error = Mailer::getLastError();
}

Message Builder

The Message class provides a fluent interface for building emails:

use Minnow\Core\Mail\Message;

$message = Message::create()
    ->to('user@example.com')                    // Single recipient
    ->to(['user1@example.com', 'user2@...'])    // Multiple recipients
    ->addTo('another@example.com')              // Add without replacing
    ->cc('cc@example.com')
    ->bcc('bcc@example.com')
    ->from('sender@example.com', 'Sender Name')
    ->replyTo('reply@example.com')
    ->subject('Email Subject')
    ->text('Plain text body')                   // OR
    ->html('<h1>HTML body</h1>')                // OR
    ->body($content, $isHtml)                   // Generic
    ->header('X-Custom-Header', 'value')
    ->headers(['X-One' => '1', 'X-Two' => '2'])
    ->attach('/path/to/file.pdf')
    ->attach('/path/to/image.jpg', 'custom-name.jpg');

// Send directly from message
$message->send();

// Or via Mailer
Mailer::send($message);

Email Templates

Templates use Mustache-style syntax with automatic HTML escaping.

Template Syntax

<!-- Escaped output (HTML entities encoded) -->
<p>Hello, {{user_name}}!</p>

<!-- Unescaped/raw output (use carefully) -->
<div>{{{html_content}}}</div>

Sending with Templates

use Minnow\Core\Mail\Mailer;
use function Minnow\Core\Mail\minnow_mail_template;

// Using helper function
minnow_mail_template('welcome', 'user@example.com', [
    'user_name' => 'John',
    'user_email' => 'john@example.com',
    'login_url' => 'https://example.com/login',
]);

// Using Mailer class
Mailer::sendTemplate('password-reset', 'user@example.com', [
    'user_name' => 'John',
    'reset_url' => 'https://example.com/reset?token=...',
    'expiry_hours' => 24,
]);

// Using Message builder with template
Message::create()
    ->to('user@example.com')
    ->template('welcome', ['user_name' => 'John'])
    ->send();

Built-in Templates

Located in core/templates/email/:

Template Variables Description
base site_name, year, content, subject, preheader Base layout wrapper
welcome user_name, user_email, login_url, site_name New user welcome
password-reset user_name, reset_url, expiry_hours Password reset request
password-changed user_name, changed_at, ip_address, reset_url Password change confirmation
notification user_name, message, action_url, action_text Generic notification
email-verification user_name, verify_url, expiry_hours Email verification
new-user-admin user_name, user_email, registered_at, user_url Admin notification
login-alert user_name, login_at, ip_address, location, device, security_url Login security alert

Creating Custom Templates

  1. Create an HTML file in core/templates/email/:
<!-- core/templates/email/order-confirmation.html -->
<p>Hi {{user_name}},</p>

<p>Your order <strong>#{{order_id}}</strong> has been confirmed!</p>

<div class="info-box">
    <p><strong>Total:</strong> {{order_total}}</p>
    <p><strong>Items:</strong> {{item_count}}</p>
</div>

<div class="button-wrapper">
    <a href="{{order_url}}" class="button">View Order</a>
</div>
  1. Use it in code:
Mailer::sendTemplate('order-confirmation', $customerEmail, [
    'user_name' => $customer->name,
    'order_id' => $order->id,
    'order_total' => '$99.00',
    'item_count' => 3,
    'order_url' => "https://example.com/orders/{$order->id}",
]);

Template CSS Classes

The base template provides these CSS classes for use in content templates:

  • .button - Primary action button (blue gradient)
  • .button-secondary - Secondary button (white with border)
  • .button-wrapper - Centered container for buttons
  • .info-box - Gray info box for metadata
  • .warning-box - Yellow warning box for alerts
  • .divider - Horizontal rule
  • .code - Inline code styling

Registering Templates Programmatically

use Minnow\Core\Mail\Template\Registry;

// Register a template from string content
Registry::register('my-template', '<p>Hello {{name}}!</p>');

// Check if template exists
if (Registry::has('welcome')) {
    // ...
}

// List all available templates
$templates = Registry::list();

Email Queue

Queue emails for background sending, with support for delays and retries.

Queueing Emails

use Minnow\Core\Mail\Mailer;
use Minnow\Core\Mail\Message;
use function Minnow\Core\Mail\minnow_mail_queue;

// Queue with helper function
minnow_mail_queue('user@example.com', 'Subject', 'Body');

// Queue with delay (1 hour)
minnow_mail_queue('user@example.com', 'Reminder', 'Don\'t forget!', 3600);

// Queue a Message object
$message = Message::create()
    ->to('user@example.com')
    ->subject('Hello')
    ->text('Body');

Mailer::queue($message);                    // Queue for immediate processing
Mailer::queue($message, delay: 3600);       // Queue with 1 hour delay
Mailer::queue($message, maxAttempts: 5);    // Custom retry attempts

// Queue directly from Message
$message->queue();
$message->queue(delay: 3600);

// Queue a templated email
Mailer::queueTemplate('welcome', 'user@example.com', [
    'user_name' => 'John',
], delay: 0, maxAttempts: 3);

Queue Processing

Queued emails are processed by the cron system or manually via CLI:

# Process pending emails
minnow mail:queue:process

# Process with custom batch size
minnow mail:queue:process --batch=100

# Process and clean up old entries
minnow mail:queue:process --cleanup --cleanup-days=30

Queue Status

minnow mail:queue:status

Output:

Email Queue Status

  Pending:    5
  Processing: 0
  Sent:       142
  Failed:     2
  Total:      149

Queue Database Schema

Table: {prefix}minnow_email_queue

Column Type Description
id BIGINT Primary key
to_addresses TEXT JSON array of recipients
subject VARCHAR(255) Email subject
body LONGTEXT Email body
is_html TINYINT(1) Whether body is HTML
headers TEXT JSON of custom headers
attachments TEXT JSON of attachment paths
template VARCHAR(100) Template name (if used)
template_vars TEXT JSON of template variables
status ENUM 'pending', 'processing', 'sent', 'failed'
attempts INT Number of send attempts
max_attempts INT Maximum retry attempts (default: 3)
scheduled_at DATETIME When to send
sent_at DATETIME When successfully sent
failed_at DATETIME When last failed
error TEXT Last error message
created_at DATETIME When queued

Mail Hooks

Events

use function Minnow\Core\Hook\on;

// Before sending (can inspect/log)
on('minnow.mail.before_send', function(Message $message, &$shouldSend, &$cancelled, &$cancelReason) {
    // Log the email
    error_log("Sending email to: " . json_encode($message->getTo()));

    // Optionally cancel
    if ($someCondition) {
        $cancelled = true;
        $cancelReason = 'Blocked by policy';
    }
});

// After successful send
on('minnow.mail.after_send', function(Message $message, string $transportName) {
    // Log success
});

// On send failure
on('minnow.mail.send_failed', function(Message $message, string $error, string $transportName) {
    // Log failure, alert admin, etc.
});

// When email is queued
on('minnow.mail.queued', function(QueuedMessage $queued, ?Message $message) {
    // Track queued emails
});

// Queue processing events
on('minnow.mail.queue.before_process', function(int $batchSize) {});
on('minnow.mail.queue.after_process', function(array $results) {});
on('minnow.mail.queue.sent', function(QueuedMessage $queued) {});
on('minnow.mail.queue.failed', function(QueuedMessage $queued, string $error) {});

Filters

use function Minnow\Core\Hook\onFilter;

// Modify message before sending
onFilter('minnow.mail.message', function(Message $message) {
    // Add tracking pixel, modify headers, etc.
    $message->header('X-Mailer', 'Minnow/1.0');
    return $message;
});

// Use custom transport
onFilter('minnow.mail.transport', function($transport, Message $message) {
    // Return a custom TransportInterface implementation
    return new MyCustomTransport();
});

// Add custom template paths
onFilter('minnow.mail.template_paths', function(array $paths) {
    $paths[] = '/path/to/plugin/templates/email';
    return $paths;
});

// Modify template after loading
onFilter('minnow.mail.template_loaded', function(Template $template, string $name) {
    // Modify template content
    return $template;
});

// Add default template variables
onFilter('minnow.mail.template_variables', function(array $variables) {
    $variables['support_email'] = 'support@example.com';
    $variables['company_name'] = 'My Company';
    return $variables;
});

Hook System

Minnow uses an event-driven architecture with events (actions) and filters.

Events

Events notify listeners that something happened. Listeners cannot modify the event data.

use function Minnow\Core\Hook\on;
use function Minnow\Core\Hook\emit;
use function Minnow\Core\Hook\off;

// Register a listener
on('user.created', function($user) {
    // Send welcome email, log, etc.
});

// With priority (lower runs first, default is 10)
on('user.created', function($user) {
    // Runs first
}, priority: 1);

on('user.created', function($user) {
    // Runs last
}, priority: 100);

// Emit an event
emit('user.created', $user);

// Remove a listener
off('user.created', $callback, priority: 10);

Filters

Filters allow modifying a value as it passes through the filter chain.

use function Minnow\Core\Hook\onFilter;
use function Minnow\Core\Hook\filter;

// Register a filter
onFilter('post.content', function($content, $post) {
    // Modify and return
    return str_replace('foo', 'bar', $content);
});

// Apply filters
$content = filter('post.content', $rawContent, $post);

Utility Functions

use function Minnow\Core\Hook\hasListener;
use function Minnow\Core\Hook\hasFilter;
use function Minnow\Core\Hook\emitCount;
use function Minnow\Core\Hook\filterCount;
use function Minnow\Core\Hook\isRunning;
use function Minnow\Core\Hook\current;

// Check if listeners exist
if (hasListener('user.created')) { }
if (hasFilter('post.content')) { }

// Count executions
$count = emitCount('user.created');
$count = filterCount('post.content');

// Check current execution
if (isRunning('user.created')) { }
$hookName = current(); // Currently executing hook name

Cron & Scheduling

Built-in Schedules

Schedule Interval
every_5_minutes 300 seconds
hourly 3600 seconds
twicedaily 43200 seconds
daily 86400 seconds
weekly 604800 seconds

Registering Tasks

use Minnow\Core\Cron\Scheduler;

// Register a recurring task
Scheduler::register(
    'my_cleanup_task',           // Unique hook name
    'daily',                     // Schedule
    [MyClass::class, 'cleanup'], // Callback
    [],                          // Arguments
    'Clean up old data'          // Description
);

// Schedule a one-time task
Scheduler::scheduleOnce(
    'send_reminder',
    time() + 3600,               // Run in 1 hour
    [Mailer::class, 'send'],
    [$message]
);

// Add custom schedule
Scheduler::addSchedule('every_30_minutes', 1800);

Running Cron

# Run all due tasks
minnow cron:run

# List registered tasks
minnow cron:list

# View execution history
minnow cron:history

Server cron setup (recommended):

* * * * * cd /path/to/minnow/public && php tools/cli/bin/minnow cron:run -q

CLI Reference

Mail Commands

# Send an email
minnow mail:send --to="user@example.com" --subject="Hello" --text="Body"
minnow mail:send --to="user@example.com" --template="welcome" --vars='{"user_name":"John"}'
minnow mail:send --to="user@example.com" --subject="Test" --text="Hi" --queue
minnow mail:send --to="user@example.com" --subject="Reminder" --text="!" --delay=3600

# Queue management
minnow mail:queue:status
minnow mail:queue:process
minnow mail:queue:process --batch=100 --cleanup

Cron Commands

minnow cron:run                    # Run due tasks
minnow cron:list                   # List all tasks
minnow cron:history                # View execution history
minnow cron:history --hook=my_task # Filter by hook

User Commands

minnow user:list
minnow user:create
minnow user:password <user_id>
minnow user:login <user_id>        # Generate login URL

Database Commands

minnow db:query "SELECT * FROM users LIMIT 5"

Cache Commands

minnow cache:clear
minnow cache:clear --type=object

Plugin Commands

minnow plugin:list
minnow plugin:analyze <plugin-dir>
minnow plugin:generate
minnow plugin:scan
minnow plugin:migrate <wp-plugin>

Core Commands

minnow core:version
minnow core:download
minnow core:install
minnow core:update

Eval Command

Evaluate PHP code in the Minnow context (non-interactive). Entity shorthand classes (Post, User, Option, Term, Comment, Attachment) and $db are available automatically.

# Evaluate an expression
minnow eval "Post::find(1)"

# Evaluate a statement
minnow eval "echo Option::get('siteurl');"

# Read from stdin
echo 'User::all()' | minnow eval

# Read from a file
minnow eval --file=script.php

Expressions are auto-returned (no need for return). Statements like echo, if, foreach, etc. are executed as-is.

Other Commands

minnow shell                       # Interactive PHP shell
minnow api:get /minnow/v1/posts    # Test API endpoints
minnow post-type:list
minnow taxonomy:list
minnow app-password:list <user_id>
minnow app-password:create <user_id> <name>
minnow app-password:delete <uuid>

Configuration

Database Configuration

admin/config.php:

<?php
return [
    'db_host' => 'localhost',
    'db_name' => 'minnow',
    'db_user' => 'root',
    'db_password' => '',
    'db_prefix' => 'minnow_',
    'db_charset' => 'utf8mb4',
];

function config(string $key, mixed $default = null): mixed {
    static $config;
    if ($config === null) {
        $config = include __FILE__;
    }
    return $config[$key] ?? $default;
}

Environment Variables

  • MINNOW_CONFIG - Path to config file (overrides default locations)

Directory Structure

public/
├── admin/                  # Admin panel
│   └── config.php          # Configuration
├── core/                   # Core framework
│   ├── Auth/               # Authentication
│   ├── Cron/               # Scheduling
│   ├── Database/           # Database abstraction
│   ├── Entity/             # Data models
│   ├── Hook/               # Event system
│   ├── Mail/               # Email system
│   │   ├── Queue/          # Queue system
│   │   ├── Template/       # Template system
│   │   └── Transport/      # Mail transports
│   ├── Plugin/             # Plugin system
│   ├── PostType/           # Custom post types
│   ├── Shortcode/          # Shortcode processing
│   └── Taxonomy/           # Taxonomies
├── data/                   # User data
│   ├── plugins/            # Installed plugins
│   ├── templates/          # Templates
│   │   └── email/          # Email templates
│   ├── themes/             # Themes
│   └── uploads/            # Media uploads
├── docs/                   # Documentation
└── tools/
    └── cli/                # CLI application
        ├── bin/minnow      # CLI entry point
        └── src/Command/    # CLI commands