mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-11 21:26:44 +00:00
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:
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 }}
|
||||
|
||||
|
||||
Reference in new issue
Block a user