Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Vmail Bundle Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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:

  1. Don't 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.)
  2. Read the repo in this order: 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.
  3. Write a Laravel migration that reproduces the columns byte-for-byte (same table names, same column names, same maildir/destination semantics). Same names means any Postfix .cf from the README works unchanged.
  4. First use case to validate end-to-end: one domain, one mailbox, one catch-all alias, provisioned via 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.

Implementation Patterns

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.

Gotchas and Tips

  • Abandoned, and it shows. 2014 release, 1 star, 0 dependents, no security response. Do not add it to 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.
  • Lowercase everything at write time. Postfix lowercases the domain (and, depending on map type/version, the whole key) before lookup. If you store [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.
  • NULL vs empty string. Postfix treats "no row" and NULL/empty result as "not found", but a map returning '' 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.
  • Postfix's %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.
  • Alias loops are silent. 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.
  • An address in both 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.
  • Plaintext passwords in the DB are a footgun. If Dovecot reads that column, it needs a prefixed scheme ({SHA512-CRYPT}, {ARGON2ID}). Also give the Postfix DB user SELECT only — it only ever reads.
  • Debugging ladder: 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.
  • Extension points are effectively nil. No events, no interfaces designed for override, no Laravel hooks. If you genuinely need to extend it, fork it — but by then you've written more code than porting the schema would have cost. The pragmatic replacement is: three tables, four .cf files, one Artisan command.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
novasa/laravel-language-center
opencage/geocode
adolfoholzer/parameters
livenetworks/ln-starter
shitoudev/permissionmanager
johannesclimacus/laravel-tui
hocvt/seotools
vitalybaev/sypexgeo
amphibee/themosis-ignition
gito-me/fast-excel
frankyso/laravel-ipaymu
geolize-studio/laravel-chile
boomerangui/chat
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
cgv/localized-resource