Instrument webhooks, fix compose layout, and ship social connect.
Add Grafana-friendly webhook request logging, Quill overflow fix, New Contact flow, in-place postcard campaign compose, and functional social account connect + composer.
This commit is contained in:
@@ -4,19 +4,47 @@
|
||||
{% block extra_head %}
|
||||
<link href="https://cdn.jsdelivr.net/npm/quill@2.0.3/dist/quill.snow.css" rel="stylesheet">
|
||||
<style>
|
||||
.ql-editor { min-height: 160px; font-family: Georgia, "Times New Roman", serif; font-size: 15px; }
|
||||
.ql-toolbar.ql-snow { border-color: var(--monica-border); border-radius: 4px 4px 0 0; }
|
||||
.ql-container.ql-snow { border-color: var(--monica-border); border-radius: 0 0 4px 4px; background: #fff; }
|
||||
#email-editor-wrap {
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
z-index: 1;
|
||||
}
|
||||
#email-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 200px;
|
||||
}
|
||||
.ql-toolbar.ql-snow {
|
||||
border-color: var(--monica-border);
|
||||
border-radius: 4px 4px 0 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ql-container.ql-snow {
|
||||
border-color: var(--monica-border);
|
||||
border-radius: 0 0 4px 4px;
|
||||
background: #fff;
|
||||
height: auto !important;
|
||||
min-height: 160px;
|
||||
flex: 1;
|
||||
overflow: visible;
|
||||
}
|
||||
.ql-editor {
|
||||
min-height: 160px;
|
||||
font-family: Georgia, "Times New Roman", serif;
|
||||
font-size: 15px;
|
||||
}
|
||||
#preview-body img { max-width: 100%; height: auto; }
|
||||
#preview-body { line-height: 1.55; color: #212121; }
|
||||
#email-editor-wrap[hidden], #sms-body-wrap[hidden] { display: none !important; }
|
||||
#email-editor-wrap[hidden],
|
||||
#sms-body-wrap[hidden],
|
||||
#postcard-body-hint[hidden] { display: none !important; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block portal_content %}
|
||||
<div class="channel-tabs" id="compose-channel-tabs">
|
||||
<a class="active" href="#compose-email" data-channel="email">Email</a>
|
||||
<a href="#compose-sms" data-channel="sms">SMS</a>
|
||||
<a href="{% url 'messaging:postcard_designer' %}" data-channel="postcard">Postcard</a>
|
||||
<a href="#compose-postcard" data-channel="postcard">Postcard</a>
|
||||
</div>
|
||||
|
||||
<div class="split">
|
||||
@@ -58,6 +86,15 @@
|
||||
oninput="syncSmsBody()">{{ form_data.body }}</textarea>
|
||||
<div class="hint">Plain text for SMS · keep it short</div>
|
||||
</div>
|
||||
<div class="field" id="postcard-body-hint" hidden>
|
||||
<p class="hint-block" style="margin:0">
|
||||
Postcard campaigns use the selected PCM design. Optional note below is stored on the draft only.
|
||||
</p>
|
||||
<label for="id_body_pc" style="margin-top:8px">Internal note <span class="muted">(optional)</span></label>
|
||||
<textarea id="id_body_pc" style="min-height:72px"
|
||||
placeholder="Optional internal note…"
|
||||
oninput="syncPostcardBody()">{{ form_data.body }}</textarea>
|
||||
</div>
|
||||
<div class="field" id="postcard-template-field">
|
||||
<label for="id_template_id">Postcard design</label>
|
||||
<select id="id_template_id" name="template_id">
|
||||
@@ -67,10 +104,12 @@
|
||||
{{ d.label }}
|
||||
</option>
|
||||
{% empty %}
|
||||
<option value="" disabled>No designs yet — open Postcard design</option>
|
||||
<option value="" disabled>No designs yet — create one first</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="hint"><a href="{% url 'messaging:postcard_designer' %}">Open postcard designer</a> to create or edit designs</div>
|
||||
<div class="hint" style="display:flex;gap:12px;flex-wrap:wrap;margin-top:8px">
|
||||
<a class="btn btn-ghost btn-sm" href="{% url 'messaging:postcard_designer' %}">Create / update postcard design</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-grid cols-2">
|
||||
<div class="field">
|
||||
@@ -165,14 +204,27 @@
|
||||
syncCampaignPreview();
|
||||
};
|
||||
|
||||
window.syncPostcardBody = function () {
|
||||
var pc = document.getElementById('id_body_pc');
|
||||
if (pc && bodyField) bodyField.value = pc.value;
|
||||
syncCampaignPreview();
|
||||
};
|
||||
|
||||
window.syncCampaignPreview = function () {
|
||||
var subject = (document.getElementById('id_subject') || {}).value || '';
|
||||
var audience = (document.getElementById('id_audience') || {}).value || '';
|
||||
var isEmail = audience === 'email_opt_in';
|
||||
var isPostcard = audience === 'postcard_opt_in';
|
||||
var body = '';
|
||||
if (isEmail && quill) {
|
||||
body = quill.root.innerHTML;
|
||||
if (body === '<p><br></p>' || body === '<p></p>') body = '';
|
||||
} else if (isPostcard) {
|
||||
var tmpl = document.getElementById('id_template_id');
|
||||
var label = tmpl && tmpl.selectedIndex >= 0 ? tmpl.options[tmpl.selectedIndex].text : '';
|
||||
body = label && tmpl.value ? ('Postcard design: ' + label) : '';
|
||||
var note = (document.getElementById('id_body_pc') || {}).value || '';
|
||||
if (note) body = (body ? body + '\n\n' : '') + note;
|
||||
} else {
|
||||
body = (bodyField && bodyField.value) || '';
|
||||
}
|
||||
@@ -188,7 +240,7 @@
|
||||
}
|
||||
empty.hidden = true;
|
||||
content.hidden = false;
|
||||
subEl.textContent = subject ? ('Subject: ' + subject) : '';
|
||||
subEl.textContent = subject ? ('Subject: ' + subject) : (isPostcard ? 'Postcard mailing' : '');
|
||||
if (isEmail) {
|
||||
bodyEl.style.whiteSpace = 'normal';
|
||||
bodyEl.innerHTML = body;
|
||||
@@ -206,19 +258,24 @@
|
||||
var tmplField = document.getElementById('postcard-template-field');
|
||||
var emailWrap = document.getElementById('email-editor-wrap');
|
||||
var smsWrap = document.getElementById('sms-body-wrap');
|
||||
var pcHint = document.getElementById('postcard-body-hint');
|
||||
var subjectField = document.getElementById('subject-field');
|
||||
if (tmplField) tmplField.style.display = isPostcard ? '' : 'none';
|
||||
if (subjectField) subjectField.style.display = isEmail ? '' : 'none';
|
||||
if (emailWrap) emailWrap.hidden = !isEmail;
|
||||
if (smsWrap) smsWrap.hidden = isEmail;
|
||||
if (smsWrap) smsWrap.hidden = !isSms;
|
||||
if (pcHint) pcHint.hidden = !isPostcard;
|
||||
if (isEmail && quill) {
|
||||
syncBodyFromQuill();
|
||||
} else if (smsField && bodyField) {
|
||||
// Keep SMS/postcard plain body in hidden field
|
||||
if (!isEmail && smsField.value === '' && bodyField.value && bodyField.value.indexOf('<') === -1) {
|
||||
} else if (isSms && smsField && bodyField) {
|
||||
if (smsField.value === '' && bodyField.value && bodyField.value.indexOf('<') === -1) {
|
||||
smsField.value = bodyField.value;
|
||||
}
|
||||
bodyField.value = smsField ? smsField.value : bodyField.value;
|
||||
bodyField.value = smsField.value;
|
||||
bodyField.removeAttribute('required');
|
||||
} else if (isPostcard && bodyField) {
|
||||
var pc = document.getElementById('id_body_pc');
|
||||
bodyField.value = pc ? pc.value : '';
|
||||
bodyField.removeAttribute('required');
|
||||
}
|
||||
document.querySelectorAll('#compose-channel-tabs a[data-channel]').forEach(function (a) {
|
||||
@@ -288,21 +345,30 @@
|
||||
document.getElementById('campaign-compose').addEventListener('submit', function () {
|
||||
var audience = (document.getElementById('id_audience') || {}).value || '';
|
||||
if (audience === 'email_opt_in') syncBodyFromQuill();
|
||||
else if (smsField) bodyField.value = smsField.value;
|
||||
else if (audience === 'sms_opt_in' && smsField) bodyField.value = smsField.value;
|
||||
else if (audience === 'postcard_opt_in') {
|
||||
var pc = document.getElementById('id_body_pc');
|
||||
bodyField.value = pc ? pc.value : '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll('#compose-channel-tabs a[data-channel="email"], #compose-channel-tabs a[data-channel="sms"]').forEach(function (a) {
|
||||
document.querySelectorAll('#compose-channel-tabs a[data-channel]').forEach(function (a) {
|
||||
a.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
var ch = a.getAttribute('data-channel');
|
||||
var audience = document.getElementById('id_audience');
|
||||
if (!audience) return;
|
||||
audience.value = ch === 'sms' ? 'sms_opt_in' : 'email_opt_in';
|
||||
if (ch === 'sms') audience.value = 'sms_opt_in';
|
||||
else if (ch === 'postcard') audience.value = 'postcard_opt_in';
|
||||
else audience.value = 'email_opt_in';
|
||||
syncComposeChannel();
|
||||
});
|
||||
});
|
||||
|
||||
var tmplSelect = document.getElementById('id_template_id');
|
||||
if (tmplSelect) tmplSelect.addEventListener('change', syncCampaignPreview);
|
||||
|
||||
initQuill();
|
||||
syncComposeChannel();
|
||||
})();
|
||||
|
||||
+102
-3
@@ -286,6 +286,76 @@ def _webhook_authorized(request, *, secret: str = "", secrets: list[str] | None
|
||||
return False
|
||||
|
||||
|
||||
def _log_webhook_request(request, *, channel: str) -> None:
|
||||
"""Full request dump for Grafana / log aggregation."""
|
||||
try:
|
||||
headers = {str(k): str(v) for k, v in request.headers.items()}
|
||||
except Exception: # noqa: BLE001
|
||||
headers = {"_error": "unable to serialize headers"}
|
||||
try:
|
||||
body_text = (request.body or b"").decode("utf-8", errors="replace")
|
||||
except Exception: # noqa: BLE001
|
||||
body_text = repr(request.body)
|
||||
if len(body_text) > 12000:
|
||||
body_text = body_text[:12000] + "…[truncated]"
|
||||
logger.info(
|
||||
"webhook_received channel=%s path=%s method=%s query=%s",
|
||||
channel,
|
||||
request.path,
|
||||
request.method,
|
||||
request.META.get("QUERY_STRING", ""),
|
||||
)
|
||||
logger.info("webhook_headers channel=%s headers=%s", channel, headers)
|
||||
logger.info("webhook_body channel=%s body=%s", channel, body_text)
|
||||
|
||||
|
||||
def _log_webhook_auth_failed(request, *, channel: str) -> None:
|
||||
logger.warning(
|
||||
"webhook_auth_failed channel=%s path=%s "
|
||||
"missing_or_invalid_authorization_or_token",
|
||||
channel,
|
||||
request.path,
|
||||
)
|
||||
|
||||
|
||||
def _log_webhook_result(
|
||||
*,
|
||||
channel: str,
|
||||
event=None,
|
||||
error: str = "",
|
||||
extra: str = "",
|
||||
) -> None:
|
||||
if error:
|
||||
logger.error(
|
||||
"webhook_error channel=%s error=%s %s",
|
||||
channel,
|
||||
error,
|
||||
extra,
|
||||
)
|
||||
return
|
||||
if not event:
|
||||
logger.warning(
|
||||
"webhook_unmatched channel=%s no_provider_event_created %s",
|
||||
channel,
|
||||
extra,
|
||||
)
|
||||
return
|
||||
message = getattr(event, "message", None)
|
||||
campaign = getattr(message, "campaign", None) if message else None
|
||||
logger.info(
|
||||
"webhook_processed channel=%s event_type=%s event_id=%s "
|
||||
"matched=%s message_id=%s campaign_id=%s campaign_name=%s %s",
|
||||
channel,
|
||||
getattr(event, "event_type", ""),
|
||||
getattr(event, "pk", None),
|
||||
bool(message),
|
||||
getattr(message, "pk", None),
|
||||
getattr(campaign, "pk", None),
|
||||
getattr(campaign, "name", "") or "",
|
||||
extra,
|
||||
)
|
||||
|
||||
|
||||
def _pcm_webhook_secrets() -> list[str]:
|
||||
"""All PCM subscription signature secrets from env."""
|
||||
raw_list = (getattr(settings, "PCM_WEBHOOK_SECRETS", None) or "").strip()
|
||||
@@ -675,12 +745,21 @@ def postcard_webhook(request):
|
||||
URL: https://<host>/portal/messaging/webhooks/postcard/
|
||||
Copy each subscription's signature secret into PCM_WEBHOOK_SECRETS
|
||||
"""
|
||||
channel = "postcard"
|
||||
_log_webhook_request(request, channel=channel)
|
||||
if not _webhook_authorized(request, secrets=_pcm_webhook_secrets()):
|
||||
_log_webhook_auth_failed(request, channel=channel)
|
||||
return HttpResponseForbidden("invalid webhook token")
|
||||
payload = parse_webhook_payload(request)
|
||||
if not payload:
|
||||
payload = request.POST.dict() or {}
|
||||
event = process_pcm_postcard_webhook(payload)
|
||||
try:
|
||||
event = process_pcm_postcard_webhook(payload)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("PCM postcard webhook processing failed")
|
||||
_log_webhook_result(channel=channel, error=str(exc))
|
||||
return JsonResponse({"ok": False, "error": "processing_failed"}, status=200)
|
||||
_log_webhook_result(channel=channel, event=event)
|
||||
return JsonResponse(
|
||||
{
|
||||
"ok": True,
|
||||
@@ -705,9 +784,12 @@ def sms_webhook(request):
|
||||
|
||||
Inbound gateway POSTs without ``event`` (text=STOP, from=…) still opt out.
|
||||
"""
|
||||
channel = "sms"
|
||||
_log_webhook_request(request, channel=channel)
|
||||
if not _webhook_authorized(
|
||||
request, secret=settings.SMTP2GO_WEBHOOK_SECRET or ""
|
||||
):
|
||||
_log_webhook_auth_failed(request, channel=channel)
|
||||
return HttpResponseForbidden("invalid webhook token")
|
||||
|
||||
payload = parse_webhook_payload(request)
|
||||
@@ -724,9 +806,21 @@ def sms_webhook(request):
|
||||
or ""
|
||||
)
|
||||
stopped = bool(phone) and record_sms_stop(str(phone))
|
||||
logger.info(
|
||||
"webhook_processed channel=sms event_type=inbound_stop "
|
||||
"opt_out=%s phone=%s",
|
||||
stopped,
|
||||
phone,
|
||||
)
|
||||
return JsonResponse({"ok": True, "opt_out": stopped})
|
||||
|
||||
event = process_smtp2go_sms_webhook(payload)
|
||||
try:
|
||||
event = process_smtp2go_sms_webhook(payload)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("SMTP2GO SMS webhook processing failed")
|
||||
_log_webhook_result(channel=channel, error=str(exc))
|
||||
return JsonResponse({"ok": False, "error": "processing_failed"}, status=200)
|
||||
_log_webhook_result(channel=channel, event=event)
|
||||
return JsonResponse(
|
||||
{
|
||||
"ok": True,
|
||||
@@ -749,16 +843,21 @@ def email_webhook(request):
|
||||
Email events: all delivery/engagement boxes
|
||||
Email headers: X-Monica-Message-Id
|
||||
"""
|
||||
channel = "email"
|
||||
_log_webhook_request(request, channel=channel)
|
||||
if not _webhook_authorized(
|
||||
request, secret=settings.SMTP2GO_WEBHOOK_SECRET or ""
|
||||
):
|
||||
_log_webhook_auth_failed(request, channel=channel)
|
||||
return HttpResponseForbidden("invalid webhook token")
|
||||
payload = parse_webhook_payload(request)
|
||||
try:
|
||||
event = process_smtp2go_email_webhook(payload)
|
||||
except Exception: # noqa: BLE001 — never 500 SMTP2GO (they retry for 48h)
|
||||
except Exception as exc: # noqa: BLE001 — never 500 SMTP2GO (they retry for 48h)
|
||||
logger.exception("SMTP2GO email webhook processing failed")
|
||||
_log_webhook_result(channel=channel, error=str(exc))
|
||||
return JsonResponse({"ok": False, "error": "processing_failed"}, status=200)
|
||||
_log_webhook_result(channel=channel, event=event)
|
||||
return JsonResponse(
|
||||
{
|
||||
"ok": True,
|
||||
|
||||
Reference in New Issue
Block a user