lasso/vmail-bundle
A Symfony bundle for virtual mail administration, offering entities and services to manage domains, mailboxes, aliases, and quotas. It integrates with Postfix/Dovecot-style vmail schemas to simplify mail account provisioning in PHP applications.
Read this first: lasso/vmail-bundle is a Symfony bundle, not a Laravel package. It has no ServiceProvider, no Facades, no Eloquent models, and no migrations. It was last released 2014‑07‑08, has 1 star, 0 dependents, and targets Symfony 2.x era PHP. Installing it into a Laravel 10/11 app will almost certainly blow up Composer (symfony/symfony ~2.x constraints, PHP 5.x syntax, Doctrine ORM 2.4 API).
So "getting started" splits into two paths:
Path A — you are actually on an old Symfony app. Register the bundle in app/AppKernel.php, import its routing/DIC config, run doctrine:schema:update, and point Postfix at the generated tables. Realistically nobody should do this in 2024+.
Path B — you are on Laravel (the likely case). Treat the repo as a schema + query spec and port it. That is the only sensible way to "leverage" this package day-to-day.
Minimal steps for Path B:
composer require it. Clone or download the tarball into reference/lasso-vmail/ so you can read it without dragging Symfony 2 into your lockfile. (Composer's --no-install / repositories: { type: package } tricks aren't worth it here.)README.md (Postfix config snippets, if present) → Resources/config/doctrine/*.orm.xml or *.yml (the actual column names and nullability — this is the source of truth) → Entity/*.php → DependencyInjection/Configuration.php (any configurable table/entity names). Typical entities in implementations of this shape are VirtualDomain, VirtualUser/VirtualMailbox, and VirtualAlias — confirm the names rather than assuming.maildir/destination semantics). Same names means any Postfix .cf from the README works unchanged.php artisan tinker, then prove it with Postfix's own lookup tool:postmap -q [email protected] mysql:/etc/postfix/sql/virtual_mailboxes.cf
postmap -q @example.com mysql:/etc/postfix/sql/virtual_aliases.cf
postconf -n | grep virtual_
If postmap -q returns nothing, your Laravel side is irrelevant — fix the DB/socket/credentials first.
Schema-first port, then Eloquent. Keep the bundle's table and column names; only the ORM changes.
// database/migrations/xxxx_create_vmail_tables.php
Schema::create('virtual_domains', function (Blueprint $t) {
$t->increments('id');
$t->string('domain')->unique();
$t->boolean('active')->default(true);
$t->timestamps();
});
Schema::create('virtual_mailboxes', function (Blueprint $t) {
$t->increments('id');
$t->string('email')->unique(); // lowercase, full address
$t->string('maildir'); // 'example.com/jane/Maildir/' <-- trailing slash
$t->string('password'); // dovecot hash, e.g. {SHA512-CRYPT}$6$...
$t->boolean('active')->default(true);
$t->timestamps();
});
Schema::create('virtual_aliases', function (Blueprint $t) {
$t->increments('id');
$t->string('source')->index(); // '[email protected]' or '@example.com'
$t->text('destination'); // comma-separated targets
$t->boolean('active')->default(true);
$t->timestamps();
});
Models are thin: protected $table, $casts = ['active' => 'boolean'], no relationships needed by Postfix.
Postfix map files are the real integration surface. Each file must return exactly one column and one row; use LIMIT 1.
# /etc/postfix/sql/virtual_mailboxes.cf (existence check)
user = vmail
hosts = 127.0.0.1
dbname = vmail
query = SELECT 1 FROM virtual_mailboxes WHERE email = '%s' AND active = 1 LIMIT 1
# /etc/postfix/sql/virtual_mailbox_paths.cf (delivery path)
query = SELECT maildir FROM virtual_mailboxes WHERE email = '%s' AND active = 1 LIMIT 1
# /etc/postfix/sql/virtual_domains.cf
query = SELECT domain FROM virtual_domains WHERE domain = '%s' AND active = 1 LIMIT 1
# /etc/postfix/sql/virtual_aliases.cf
query = SELECT destination FROM virtual_aliases WHERE source = '%s' AND active = 1 LIMIT 1
# main.cf
virtual_mailbox_domains = proxy:mysql:/etc/postfix/sql/virtual_domains.cf
virtual_mailbox_maps = proxy:mysql:/etc/postfix/sql/virtual_mailbox_paths.cf
virtual_alias_maps = proxy:mysql:/etc/postfix/sql/virtual_aliases.cf
virtual_mailbox_base = /var/vmail
virtual_uid_maps = static:5000
virtual_gid_maps = static:5000
Note the two mailbox files: one answers "does this address exist" (any single column), the other answers "where does it go". Many blog posts conflate them and then wonder why delivery lands in the wrong place.
Alias semantics to model explicitly. virtual_alias_maps is consulted before mailbox lookup. A source of @example.com is a domain catch-all. Destinations are comma-separated and can be local ([email protected]) or remote ([email protected], which turns the message into a relay). Aliases can chain.
Provisioning workflow. Put domain+mailbox+alias creation behind one transactional Artisan command rather than letting admins poke at rows:
DB::transaction(function () use ($domain, $local, $password) {
VirtualDomain::firstOrCreate(['domain' => $domain], ['active' => true]);
VirtualMailbox::create([
'email' => strtolower("{$local}@{$domain}"),
'maildir' => "{$domain}/{$local}/Maildir/",
'password'=> '{SHA512-CRYPT}' . crypt($password, '$6$' . Str::random(16)),
]);
});
Prefer doveadm pw -s SHA512-CRYPT over hand-rolled crypt() if Dovecot is in the loop — it guarantees the exact format Dovecot expects.
Delivery architecture tip: if you run Dovecot, don't use Postfix's virtual(8) agent at all. Set virtual_transport = lmtp:unix:private/dovecot-lmtp and let Dovecot's userdb/passdb SQL queries read the same tables. Postfix then only needs the domain and alias maps; maildir paths and uid/gid stay in one place. The bundle's schema supports this fine — the maildir column just becomes Dovecot's mail_location source.
Admin UI: a Filament or Nova resource over these three models is the whole "day-to-day" story. The value of the bundle is the schema and the query strings, not the code.
Testing: write a small AddressResolver service that mirrors Postfix's lookup order (alias → alias-chain limit → mailbox → domain), and unit-test it. You cannot meaningfully integration-test virtual_alias_maps without a running Postfix, and postmap -q in a CI step is the cheapest smoke test.
composer.json in a production Laravel app; vendor the schema and the query strings and delete the rest. If a CVE lands in its Symfony 2 dependency tree, you own it forever.maildir must end in a slash. example.com/jane/Maildir/ → maildir delivery. Drop the trailing slash and Postfix writes a single mbox file, silently, and Dovecot will show an empty inbox. This is the single most common failure.[email protected], %s won't match unless your collation happens to be case-insensitive. Normalize on insert and add a unique index; don't rely on utf8mb4_unicode_ci to save you — it behaves differently from Postgres, which matters if you ever migrate.'' can be treated as found-with-empty-value by some agents. Make active = 0 the disable mechanism rather than nulling columns.proxy: maps and chroot. Postfix often runs chrooted; MySQL/Postgres libs and the .cf files may be unreachable inside the jail. Use proxy:mysql:... (proxymap) as shown above, or set *_chroot = n for the smtpd service. Also: every RCPT TO triggers a query with no negative caching — index email and source, and watch max_connections if you're on a shared DB.%s is not a prepared statement. Postfix quotes the lookup key, but don't make it harder: no LIKE, no %u-concatenation tricks, no interpolating domains straight from inbound headers. Validate and lower-case addresses in Laravel with the email rule plus idn_to_ascii() for IDN domains.a@ → b@, b@ → a@ hits virtual_alias_recursion_limit and the mail bounces or vanishes depending on version. Validate at write time: refuse an alias whose destination set transitively contains its source. Same for a catch-all pointing at an address inside the same domain.virtual_aliases and virtual_mailboxes. Postfix resolves the alias first; the mailbox row is dead weight and confusing. Enforce one-or-the-other in your admin UI.{SHA512-CRYPT}, {ARGON2ID}). Also give the Postfix DB user SELECT only — it only ever reads.postmap -q '<addr>' mysql:/etc/postfix/sql/<file>.cf → postconf -n → tail -f /var/log/mail.log (look for dict_mysql_lookup: ...) → postqueue -p. Nine times out of ten postmap -q tells you the truth before any Laravel log does..cf files, one Artisan command.How can I help you explore Laravel packages today?