NAME
Configd::Language::postfix - main.cf and master.cf, which postfix has never had a conf.d for.
VERSION
version 0.002
SYNOPSIS
use Configd();
Configd->adopt('postfix'); # main.cf and master.cf become generated
Configd->build('postfix'); # which is what the systemd drop-in then runs
From a shell, which is how it is actually used:
configd adopt postfixprintf 'mydestination = example.com\n' > /etc/postfix/main.cf.d/50-example.cfsystemctl restart postfix
DESCRIPTION
Postfix has postconf -e and nothing else. It sets a parameter by rewriting main.cf, which is fine for a person at a terminal and wrong for anything automated: two things configuring the same server cannot both set mydestination, because the second one to run replaces what the first wrote rather than adding to it. Hosting two domains on one mail server is enough to hit it, and the failure is quiet -- mail for the first domain simply stops being local.
So main.cf and master.cf become generated files with main.cf.d and master.cf.d beside them, and each domain drops in a fragment naming itself. The parameters that are lists are merged as lists; see "ACCUMULATING PARAMETERS".
What the fragments look like
Exactly like the file they add to, because that is the point -- anything you would have written in main.cf is a fragment:
# /etc/postfix/main.cf.d/50-example.com.cf
mydestination = example.com
virtual_mailbox_domains = example.com
virtual_mailbox_maps = hash:/etc/postfix/virtual/maps
NAME
Configd::Language::postfix - main.cf and master.cf, which postfix has never had a conf.d for.
ACCUMULATING PARAMETERS
sender_dependent_relayhost_maps and smtpd_sender_login_maps are here for the same reason as the rest and were missed the first time, which is worth naming because the second one fails dangerously. It is what reject_authenticated_sender_login_mismatch reads, so a host where it does not accumulate ends up naming one domain's table -- and every other domain's users, whose addresses are then owned by nobody, are refused when they try to send.
The parameters postfix documents as comma-or-space separated lists, where two fragments each naming a domain, a map or a milter both meant it: mydestination, mynetworks, relay_domains, the virtual_* family, the *_maps and *_checks families, smtpd_milters and inet_interfaces among them. Anything else is a value, and a later fragment replaces it.
The *_restrictions parameters are deliberately not accumulated even though they are lists. They are ordered, the order is what they mean, and joining two of them end to end gives something that parses and that neither fragment asked for -- a permit_ landing ahead of a check that was supposed to run first is an open relay. Two fragments disagreeing about a restriction list is something a person should look at.
WHAT THIS DOES NOT REACH: THE LOOKUP TABLES
main.cf is full of paths, and none of them are this language's business. virtual_mailbox_maps names a file of addresses; header_checks names a file of patterns; check_recipient_access names one from inside a restriction list. Adopting main.cf merges the parameters that name those tables and does nothing whatever to the tables themselves, which is worth saying out loud because the parameter merging cleanly is exactly what makes it easy to believe the problem is solved.
Where the parameter accumulates the tables come along for free, because postfix searches a list of them in order. Two domains each writing their own file and each naming it is enough:
# 50-first.example.com
virtual_mailbox_maps = hash:/etc/postfix/virtual/first.example.com
# 50-second.example.com
virtual_mailbox_maps = hash:/etc/postfix/virtual/second.example.com
That is the whole answer for virtual_mailbox_maps, virtual_alias_maps, transport_maps, header_checks and the rest of the accumulating list, and it needs nothing from this distribution.
It is not available for a table named from inside a restriction list. check_recipient_access pcre:/etc/postfix/recipient_access lives inside smtpd_recipient_restrictions, which does not accumulate and must not, so the path in it is whatever the last fragment to mention that parameter said. Every domain therefore shares one table, and the second one provisioned overwrites the first one's -- the same failure adopting main.cf was meant to end, one level down and out of reach.
Two things follow, and both are the caller's rather than this language's:
A shared table has to be assembled rather than merged, because these are ordered files. A pcre or regexp table is read top to bottom and the first match wins, so a catch-all belongs at the end and concatenating two domains' tables puts one in the middle. Numeric prefixes on the fragments, and the catch-all last, is the shape that works -- the same shape configd gives a config file, which is not a coincidence but is not implemented here either.
Before building any of that, check whether the table is needed at all. Postfix rejects a recipient in a virtual mailbox domain that is absent from
virtual_mailbox_mapsby itself -- "User unknown in virtual mailbox table" -- and one in a local domain absent fromlocal_recipient_mapslikewise, so an access table written to reject unknown recipients is often restating a check postfix already makes, and is only load-bearing because the configuration has a domain in two address classes at once. https://www.postfix.org/ADDRESS_CLASS_README.html is the page;VIRTUAL_READMEis blunter about it: "NEVER list a virtual MAILBOX domain name as a mydestination domain!"
METHODS
files()
main.cf and master.cf.
units()
postfix@.service, the templated unit.
services()
postfix.service, which is what can actually be restarted.
accumulates($key)
True for the list parameters above. Never true of a master.cf entry, which is a row rather than a list: two fragments configuring one service disagree about it, and the later one wins. The base class's comma is therefore the only separator this language ever needs.
parse($text)
Read a fragment of either file.
Which one is worked out from the text rather than from a filename, because a main.cf line and a master.cf line cannot be mistaken for each other: the first has an = and the second is a row of columns.
emit($directives)
Write the file back. Which file, again, from what is in it.
SEE ALSO
Please see those modules/websites for more information related to this module.
BUGS
Please report any bugs or feature requests on the bugtracker website https://github.com/teodesian/perl-configd/issues
When submitting a bug or request, please include a test-file or a patch to an existing test-file that illustrates the bug or desired feature.
AUTHORS
Current Maintainers:
George S. Baugh <george@troglodyne.net>
COPYRIGHT AND LICENSE
Copyright (c) 2026 Troglodyne LLC
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.