fix(erpnext): self-heal outgoing Email Account password from SMTP_* env
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).
This commit is contained in:
parent
1eb29f80be
commit
557b45f65d
4 changed files with 161 additions and 21 deletions
|
|
@ -0,0 +1,122 @@
|
|||
"""
|
||||
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(),
|
||||
)
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
[
|
||||
{
|
||||
"name": "Performance West Outgoing",
|
||||
"doctype": "Email Account",
|
||||
"email_id": "noreply@performancewest.net",
|
||||
"email_account_name": "Performance West Outgoing",
|
||||
"smtp_server": "co.carrierone.com",
|
||||
"smtp_port": 587,
|
||||
"use_tls": 1,
|
||||
"login_id": "noreply@performancewest.net",
|
||||
"enable_outgoing": 1,
|
||||
"enable_incoming": 0,
|
||||
"default_outgoing": 1,
|
||||
"awaiting_password": 1,
|
||||
"used_for_outgoing_emails_from": "System Notifications",
|
||||
"add_signature": 0,
|
||||
"auto_follow_threads": 0,
|
||||
"enable_auto_reply": 0
|
||||
}
|
||||
]
|
||||
|
|
@ -10,7 +10,13 @@ fixtures = [
|
|||
{"dt": "Custom Field", "filters": [["dt", "in", ["Sales Order", "Sales Invoice", "Payment Request"]]]},
|
||||
{"dt": "Notification", "filters": [["name", "like", "CRTC%"]]},
|
||||
{"dt": "Notification", "filters": [["name", "like", "Admin %"]]},
|
||||
{"dt": "Email Account", "filters": [["email_account_name", "=", "Performance West Outgoing"]]},
|
||||
# NB: the "Performance West Outgoing" Email Account is intentionally NOT a
|
||||
# fixture. Its SMTP password is stored encrypted (per-site encryption_key)
|
||||
# and cannot be carried in a fixture, so reimporting it on `bench migrate`
|
||||
# would set awaiting_password=1 and an empty password — breaking all
|
||||
# outgoing mail ("Password not found for Email Account ..."). The account is
|
||||
# created/repaired idempotently from the SMTP_* env via
|
||||
# email_account_sync.sync_outgoing_password (after_migrate + scheduler).
|
||||
# Subscription plans for recurring renewals (RA, annual report, CRTC maintenance,
|
||||
# formation maintenance bundles). Pricing updated per go-live-todo.md:37, 261.
|
||||
{"dt": "Subscription Plan"},
|
||||
|
|
@ -44,3 +50,19 @@ doc_events = {
|
|||
csrf_ignore_methods = [
|
||||
"performancewest_erpnext.api.stripe_webhook",
|
||||
]
|
||||
|
||||
# Reconcile the outgoing Email Account password from the SMTP_* environment at
|
||||
# the end of every migrate (fixtures import just before this, and cannot carry
|
||||
# the encrypted password). Keeps "Password not found for Email Account ..." from
|
||||
# recurring after a deploy.
|
||||
after_migrate = [
|
||||
"performancewest_erpnext.email_account_sync.sync_outgoing_password",
|
||||
]
|
||||
|
||||
# Daily self-heal: catches drift from out-of-band restarts / DB restores that
|
||||
# wipe the encrypted password without going through migrate.
|
||||
scheduler_events = {
|
||||
"daily": [
|
||||
"performancewest_erpnext.email_account_sync.sync_outgoing_password",
|
||||
],
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue