fix(web-app-espocrm): provision the mailer EspoCRM 9.1 actually sends from

EspoCRM moved the system SMTP out of Settings in 9.1. Sender::applyGlobal
resolves the account solely through SendingAccountProvider::getSystem,
which looks for an InboundEmail row with status Active, useSmtp true and
an emailAddress matching outboundEmailFromAddress; it never reads
config.smtpServer. EmailSender::hasSystemSmtp still checks that config
key, so the deployment reported SMTP as configured and threw NoSmtp on a
send. Verified against the pinned 10.0.10, including the V9_1 migration
whose field mapping this follows.

The role now seeds that record. It reads the SMTP parameters from
EspoCRM's own config rather than carrying them across Ansible a second
time, encrypts the password the way the API input filter would, and
compares by decrypting so a random IV does not make every deploy look
changed. name and useImap are written on create only, so an operator's
own IMAP settings are not clobbered.

The spec asserted [data-name='smtpServer'] on the Outbound Emails page.
That field has not existed since 9.1 and the role has been pinned to 10.0.x
since August, so the assertion could never pass; c83c00da7d wrote it
against a pre-9.1 EspoCRM. It now reads the record through EspoCRM's own
API and asserts exactly the predicates getSystem needs. The in-page step
projects scalars out of the browser rather than the record, which carries
the encrypted password and the session token.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5 committed 2026-10-17 21:45:39 +02:00
1 parent d449f00018
commit 0fd3e3b364
4 files changed
+247 -12

No files matched your search

@@ -0,0 +1,147 @@
<?php
// nocheck: mirrored-unit-test - requires /var/www/html/bootstrap.php at the top level
// and rewrites the live InboundEmail table through EspoCRM's own entity manager
/**
* Provision the Group Email Account EspoCRM sends system mail through.
*
* Since 9.1 Espo\Core\Mail\Sender::applyGlobal() takes the system mailer from
* SendingAccountProvider::getSystem(), which resolves an Active InboundEmail row with
* useSmtp whose emailAddress equals outboundEmailFromAddress, and never reads
* config.smtpServer. EmailSender::hasSystemSmtp() still reads that config key, so
* without this row EspoCRM reports SMTP as configured and then throws
* NoSmtp("No system SMTP settings.") on an actual send.
*
* Reads: the app's own config, seeded from templates/env.j2 via ESPOCRM_CONFIG_SMTP_*.
* Exit: 0 with [CHANGED] or [UNCHANGED] on stdout, 1 with [FAIL] on stderr.
*/
require "/var/www/html/bootstrap.php";
$app = new \Espo\Core\Application();
$app->setupSystemUser();
$container = $app->getContainer();
$config = $container->get('config');
$crypt = $container->get('crypt');
$entityManager = $container->getByClass(\Espo\Core\ORM\EntityManager::class);
$address = $config->get('outboundEmailFromAddress');
$host = $config->get('smtpServer');
$port = $config->get('smtpPort');
$security = $config->get('smtpSecurity');
$auth = $config->get('smtpAuth') === true;
$username = $config->get('smtpUsername');
$password = $config->get('smtpPassword');
if (!is_string($address) || $address === '') {
fwrite(STDERR, "[FAIL] config outboundEmailFromAddress is empty; the system account is resolved by that address\n");
exit(1);
}
if (!is_string($host) || $host === '') {
fwrite(STDERR, "[FAIL] config smtpServer is empty; there is no mail host to carry onto the group email account\n");
exit(1);
}
if (!is_int($port)) {
fwrite(STDERR, "[FAIL] config smtpPort is not an integer; Espo rejects the account with NoSmtp(\"Empty port.\")\n");
exit(1);
}
if (!is_string($security)) {
fwrite(STDERR, "[FAIL] config smtpSecurity is not a string; a null would silently downgrade the transport to plaintext\n");
exit(1);
}
if ($auth && (!is_string($username) || $username === '')) {
fwrite(STDERR, "[FAIL] config smtpAuth is on but smtpUsername is empty\n");
exit(1);
}
if ($auth && !is_string($password)) {
fwrite(STDERR, "[FAIL] config smtpAuth is on but smtpPassword is not a string\n");
exit(1);
}
$password = $auth ? $password : null;
$repository = $entityManager->getRDBRepositoryByClass(\Espo\Entities\InboundEmail::class);
$account = $repository
->where(
\Espo\ORM\Query\Part\Condition::equal(
\Espo\ORM\Query\Part\Expression::lowerCase(
\Espo\ORM\Query\Part\Expression::column('emailAddress')
),
strtolower($address)
)
)
->findOne();
$changed = false;
if (!$account) {
$account = $repository->getNew();
$account->set('name', $address . ' (system)');
$account->set('useImap', false);
$changed = true;
}
$values = [
'emailAddress' => $address,
'status' => \Espo\Entities\InboundEmail::STATUS_ACTIVE,
'useSmtp' => true,
'smtpHost' => $host,
'smtpPort' => $port,
'smtpSecurity' => $security,
'smtpAuth' => $auth,
'smtpUsername' => $username,
'fromName' => $config->get('outboundEmailFromName'),
];
foreach ($values as $attribute => $value) {
if ($account->get($attribute) === $value) {
continue;
}
$account->set($attribute, $value);
$changed = true;
}
$stored = $account->get('smtpPassword');
$current = null;
if (is_string($stored) && $stored !== '') {
try {
$current = $crypt->decrypt($stored);
} catch (\Throwable) {
$current = null;
}
}
if ($current !== $password) {
$account->set('smtpPassword', is_string($password) ? $crypt->encrypt($password) : null);
$changed = true;
}
if (!$changed) {
echo "[UNCHANGED] the group email account already carries the deployed mail host\n";
exit(0);
}
try {
$entityManager->saveEntity($account, [
\Espo\Core\ORM\Repository\Option\SaveOption::SKIP_HOOKS => true,
]);
} catch (\Throwable $exc) {
fwrite(STDERR, sprintf(
"[FAIL] saving the group email account failed: %s: %s (%s:%d)\n",
get_class($exc),
$exc->getMessage(),
$exc->getFile(),
$exc->getLine()
));
exit(1);
}
echo "[CHANGED] the group email account now carries the deployed mail host\n";
@@ -17,6 +17,7 @@ test.use({ ignoreHTTPSErrors: true });
const appBaseUrl = normalizeBaseUrl(process.env.APP_BASE_URL || "");
const canonicalDomain = decodeDotenvQuotedValue(process.env.CANONICAL_DOMAIN || "");
const smtpHost = decodeDotenvQuotedValue(process.env.EMAIL_SMTP_HOST || "");
const smtpPort = decodeDotenvQuotedValue(process.env.EMAIL_SMTP_PORT || "");
const adminUsername = decodeDotenvQuotedValue(process.env.ADMIN_USERNAME || "");
const adminPassword = decodeDotenvQuotedValue(process.env.ADMIN_PASSWORD || "");
const adminNativePassword = decodeDotenvQuotedValue(process.env.ADMIN_NATIVE_PASSWORD || "");
@@ -28,6 +29,11 @@ test("addon outbound-emails: EspoCRM sends through the deployed Mailu SMTP host"
expect(appBaseUrl, "APP_BASE_URL must be set").toBeTruthy();
expect(smtpHost, "EMAIL_SMTP_HOST must be set when the email service is on").toBeTruthy();
expect(smtpPort, "EMAIL_SMTP_PORT must be set when the email service is on").toBeTruthy();
expect(
Number.isInteger(Number(smtpPort)) && Number(smtpPort) > 0,
"EMAIL_SMTP_PORT must be a port number when the email service is on",
).toBe(true);
expect(adminUsername, "ADMIN_USERNAME must be set").toBeTruthy();
const expectedBase = appBaseUrl.replace(/\/$/, "");
@@ -76,23 +82,92 @@ test("addon outbound-emails: EspoCRM sends through the deployed Mailu SMTP host"
"the login must mint an EspoCRM session before the admin route is opened",
);
await gotoOnion(page, `${expectedBase}/#Admin/outboundEmails`, { waitUntil: "domcontentloaded" });
await gotoOnion(page, `${expectedBase}/#Admin/groupEmailAccounts`, { waitUntil: "domcontentloaded" });
await page.reload({ waitUntil: "domcontentloaded" });
const smtpServerField = page.locator("[data-name='smtpServer'], input[name='smtpServer']").first();
await expect(
smtpServerField,
"the Outbound Emails admin panel must render the SMTP server field; without it the login " +
"did not reach an administrator surface and the assertion below would prove nothing",
page.locator("[data-scope='InboundEmail'] tr.list-row").first(),
"the Group Email Accounts admin page must list at least one account; an empty list or an " +
"access-denied page means the login did not reach an administrator surface and the " +
"assertions below would prove nothing",
).toBeAttached({ timeout: resolveTimeout(120_000) });
const rendered =
(await smtpServerField.inputValue().catch(() => null)) ??
(await smtpServerField.innerText().catch(() => ""));
const probe = await page.evaluate(async () => {
const key = Object.keys(localStorage).find((name) => /user-auth$/.test(name));
const token = key ? localStorage.getItem(key) : null;
if (!token) {
return { token: false };
}
const response = await fetch("api/v1/InboundEmail?maxSize=200", {
headers: {
"Espo-Authorization": token,
"Espo-Authorization-By-Token": "true",
},
});
if (!response.ok) {
return { token: true, httpStatus: response.status };
}
const payload = await response.json();
const record = (payload.list || []).find((entry) => entry.isSystem === true);
return {
token: true,
httpStatus: response.status,
account: record
? {
status: record.status,
useSmtp: record.useSmtp,
smtpHost: record.smtpHost,
smtpPort: record.smtpPort,
smtpAuth: record.smtpAuth,
}
: null,
};
});
expect(
rendered,
`EspoCRM's outbound mailer must carry the deployed Mailu host (${smtpHost}); another value ` +
"means ESPOCRM_CONFIG_SMTP_SERVER never reached the app's own config",
).toContain(smtpHost);
probe.token,
"the authenticated session must leave EspoCRM's auth token in localStorage for the API read",
).toBe(true);
expect(
probe.httpStatus,
"InboundEmail is an admin-only scope, so anything but 200 means the session that reached " +
"the admin page is not an administrator's",
).toBe(200);
expect(
probe.account,
"EspoCRM must hold a group email account it reports as the system one; isSystem is true " +
"only for the record whose address is outboundEmailFromAddress, which is exactly the row " +
"Sender::applyGlobal resolves through SendingAccountProvider::getSystem() since 9.1; " +
"config.smtpServer is no longer read on send",
).not.toBeNull();
expect(
probe.account.status,
"the system group email account must be Active; getSystem() filters on that status",
).toBe("Active");
expect(
probe.account.useSmtp,
"the system group email account must have SMTP enabled; getSystem() filters on that flag",
).toBe(true);
expect(
probe.account.smtpHost,
`the system group email account must carry the deployed mail host (${smtpHost}); another ` +
"value means the role never provisioned it from the deployed mail service",
).toBe(smtpHost);
expect(
probe.account.smtpPort,
`the system group email account must carry the deployed submission port (${smtpPort}); a ` +
'null port makes getSmtpParams() throw NoSmtp("Empty port.") on every send',
).toBe(Number(smtpPort));
expect(
probe.account.smtpAuth,
"the system group email account must authenticate; the deployed mail service rejects an " +
"unauthenticated submission",
).toBe(true);
});
@@ -94,5 +94,16 @@
changed_when: espocrm_admin_password.rc == 0
delegate_to: "{{ ESPOCRM_HOST_NODE }}"
- name: "📮 Provision the group email account EspoCRM sends system mail through"
ansible.builtin.shell:
cmd: |
# nocheck: container-exec-resolver runtime-resolved CID, delegated to the hosting node
container exec -i --user {{ ESPOCRM_USER }} {{ ESPOCRM_LOCAL_CID | quote }} php
stdin: "{{ lookup('file', 'php/seed_group_email_account.php') }}"
register: espocrm_group_email_account
changed_when: espocrm_group_email_account.stdout is search('\[CHANGED\]')
delegate_to: "{{ ESPOCRM_HOST_NODE }}"
when: lookup('email', application_id).enabled | bool
- name: "🎨 Apply the corporate design of {{ application_id }}"
ansible.builtin.include_tasks: 03_design.yml
@@ -27,6 +27,8 @@ EMAIL_SERVICE_ENABLED={{ lookup('config', application_id, 'services.email.enable
EMAIL_SMTP_HOST={{ lookup('email', application_id).host | dotenv_quote }}
EMAIL_SMTP_PORT={{ lookup('email', application_id).port }}
# nocheck: playwright-service-gate — the persona drives the OIDC login path; ESPOCRM_CONFIG_AUTHENTICATION_METHOD=LDAP is exercised in the LDAP-only matrix variant
LDAP_SERVICE_ENABLED={{ lookup('config', application_id, 'services.ldap.enabled') | string | lower }}