Root cause of recurring 'Password not found for Email Account Performance West Outgoing': the account was shipped as a fixture with awaiting_password=1 and no password. Email Account SMTP passwords are encrypted per-site and cannot live in a fixture, so every `bench migrate` reimported the fixture and re-broke outgoing mail (login notifications, password resets, welcome emails). - Remove the Email Account fixture (it cannot carry the encrypted secret). - Add email_account_sync.sync_outgoing_password: idempotent, exception-safe upsert that reconciles the account + password from SMTP_* env and clears awaiting_password. - Wire it to after_migrate (repairs at end of every deploy/migrate, right after fixtures import) and the daily scheduler (heals out-of-band restore/restart drift). - Pass SMTP_* into the erpnext + erpnext-scheduler containers so the sync has the secret (they previously had no SMTP env).
122 lines
4.7 KiB
Python
122 lines
4.7 KiB
Python
"""
|
|
Self-healing sync for the outgoing Email Account password.
|
|
|
|
Background
|
|
----------
|
|
ERPNext stores the SMTP password for an Email Account encrypted in the
|
|
``__Auth`` table, keyed by the site's ``encryption_key``. That secret is NOT
|
|
portable: it cannot live in a fixture, in git, or in a DB dump that gets
|
|
restored under a different key. As a result the "Performance West Outgoing"
|
|
account would periodically end up with ``awaiting_password = 1`` and an empty
|
|
password, after which every outgoing send (login notifications, password
|
|
resets, welcome emails) throws::
|
|
|
|
Password not found for Email Account Performance West Outgoing
|
|
|
|
The recurrence vectors we have actually hit:
|
|
* ``bench migrate`` reimporting an Email Account *fixture* (fixtures can't
|
|
carry the encrypted password, and the fixture set ``awaiting_password=1``).
|
|
* a site DB restore from a backup taken under a different encryption key.
|
|
|
|
Fix
|
|
---
|
|
Treat the *environment* (``SMTP_*`` vars, same ones the API/workers use) as the
|
|
single source of truth and reconcile the Email Account doc to it. This runs:
|
|
* ``after_migrate`` — repairs the account at the end of every deploy/migrate,
|
|
immediately after fixtures import, so migrate can never leave it broken.
|
|
* daily via the scheduler — heals drift from out-of-band restarts / restores.
|
|
|
|
The function is idempotent and never raises into the migrate/scheduler flow:
|
|
a misconfigured or missing secret must not abort a deploy.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
|
|
import frappe
|
|
|
|
# The single outgoing account this app manages. Matches the value formerly
|
|
# shipped as a fixture (email_account_name / name).
|
|
ACCOUNT_NAME = "Performance West Outgoing"
|
|
|
|
|
|
def _env(*names: str, default: str = "") -> str:
|
|
"""First non-empty value among the given environment variable names."""
|
|
for n in names:
|
|
v = os.environ.get(n)
|
|
if v:
|
|
return v
|
|
return default
|
|
|
|
|
|
def sync_outgoing_password() -> None:
|
|
"""Reconcile the outgoing Email Account to the SMTP_* environment.
|
|
|
|
Idempotent and exception-safe — logs and returns on any problem so it can
|
|
be wired into ``after_migrate`` and ``scheduler_events`` without risk of
|
|
aborting a deploy or a scheduler tick.
|
|
"""
|
|
try:
|
|
host = _env("SMTP_HOST")
|
|
user = _env("SMTP_USER", "SMTP_FROM")
|
|
password = _env("SMTP_PASS")
|
|
port = _env("SMTP_PORT", default="587")
|
|
# email_id is the From address; fall back to the login user.
|
|
email_id = _env("SMTP_FROM_EMAIL", "SMTP_USER") or user
|
|
|
|
if not (host and user and password):
|
|
# No secret available in this environment — leave the account as-is
|
|
# rather than wiping a working password. (e.g. dev box with no SMTP.)
|
|
frappe.logger("pw_email_sync").info(
|
|
"SMTP_HOST/USER/PASS not all set; skipping outgoing password sync"
|
|
)
|
|
return
|
|
|
|
try:
|
|
port_int = int(str(port).strip())
|
|
except (TypeError, ValueError):
|
|
port_int = 587
|
|
|
|
exists = frappe.db.exists("Email Account", ACCOUNT_NAME)
|
|
if exists:
|
|
doc = frappe.get_doc("Email Account", ACCOUNT_NAME)
|
|
else:
|
|
doc = frappe.new_doc("Email Account")
|
|
doc.email_account_name = ACCOUNT_NAME
|
|
# `name` is set from email_account_name on insert; set explicitly so
|
|
# downstream references (notifications, the fixture name) line up.
|
|
doc.name = ACCOUNT_NAME
|
|
|
|
# Reconcile connection settings + the password from the environment.
|
|
doc.email_id = email_id
|
|
doc.login_id = user
|
|
doc.login_id_is_different = 1 if (user and user != email_id) else 0
|
|
doc.smtp_server = host
|
|
doc.smtp_port = port_int
|
|
doc.use_tls = 1
|
|
doc.enable_outgoing = 1
|
|
doc.default_outgoing = 1
|
|
doc.password = password
|
|
# The whole point: clear the flag that makes ERPNext refuse to send.
|
|
doc.awaiting_password = 0
|
|
|
|
# Skip the live SMTP round-trip validation on save (deploys run offline
|
|
# / behind egress controls); a separate connectivity check covers that.
|
|
doc.flags.ignore_validate = True
|
|
if exists:
|
|
doc.save(ignore_permissions=True)
|
|
else:
|
|
doc.insert(ignore_permissions=True)
|
|
frappe.db.commit()
|
|
|
|
frappe.logger("pw_email_sync").info(
|
|
f"Synced outgoing Email Account '{ACCOUNT_NAME}' "
|
|
f"({user}@{host}:{port_int}); awaiting_password cleared"
|
|
)
|
|
except Exception:
|
|
# Never abort migrate / scheduler. Log full traceback for diagnosis.
|
|
frappe.log_error(
|
|
title="pw outgoing email account sync failed",
|
|
message=frappe.get_traceback(),
|
|
)
|