Improve outreach compose, contact merge, and email assets.
Deploy Beta / unit-tests (push) Successful in 10s
Deploy Beta / docker (push) Successful in 14s
Deploy Beta / deploy-beta (push) Successful in 1m40s

Add a Quill email editor with DB-backed image storage, selectable PCM designs, postcard defaults for addressed contacts, and merge-by-phone/address on the contact form.
This commit is contained in:
2026-08-09 10:42:22 -05:00
parent d3db6eed76
commit b3a6ee0cd0
19 changed files with 1158 additions and 129 deletions
+202
View File
@@ -0,0 +1,202 @@
"""Contact identity matching and merge helpers."""
from __future__ import annotations
import re
from contacts.models import Contact
_PHONE_MIN_DIGITS = 10
def normalize_phone_digits(value: str) -> str:
return "".join(ch for ch in (value or "") if ch.isdigit())
def phones_match(a: str, b: str) -> bool:
"""True when both phones have ≥10 digits and last-10 match."""
da = normalize_phone_digits(a)
db = normalize_phone_digits(b)
if len(da) < _PHONE_MIN_DIGITS or len(db) < _PHONE_MIN_DIGITS:
return False
return da[-10:] == db[-10:]
def _norm_addr_part(value: str) -> str:
text = (value or "").strip().lower()
text = re.sub(r"[.,#]", " ", text)
return re.sub(r"\s+", " ", text).strip()
def addresses_match(a: dict | None, b: dict | None) -> bool:
"""
Strong postal match: same street line1 + ZIP, or line1 + city + state.
Empty / partial addresses never match.
"""
if not a or not b:
return False
line1_a = _norm_addr_part(a.get("line1") or "")
line1_b = _norm_addr_part(b.get("line1") or "")
if not line1_a or not line1_b or line1_a != line1_b:
return False
zip_a = _norm_addr_part(a.get("zip") or "")
zip_b = _norm_addr_part(b.get("zip") or "")
if zip_a and zip_b:
return zip_a[:5] == zip_b[:5]
city_a = _norm_addr_part(a.get("city") or "")
city_b = _norm_addr_part(b.get("city") or "")
state_a = _norm_addr_part(a.get("state") or "")
state_b = _norm_addr_part(b.get("state") or "")
if city_a and city_b and state_a and state_b:
return city_a == city_b and state_a == state_b
return False
def find_matching_contact(
*,
email: str = "",
phone: str = "",
postal_address: dict | None = None,
) -> tuple[Contact | None, str]:
"""
Resolve an existing contact.
Priority: email → phone (last 10) → mailing address.
Returns (contact, reason) where reason is email|phone|address|"".
"""
email_norm = (email or "").strip().lower()
if email_norm:
hit = Contact.objects.filter(email__iexact=email_norm).first()
if hit:
return hit, "email"
phone_digits = normalize_phone_digits(phone)
if len(phone_digits) >= _PHONE_MIN_DIGITS:
tail = phone_digits[-10:]
for row in Contact.objects.exclude(phone="").only("id", "phone").iterator():
other = normalize_phone_digits(row.phone)
if len(other) >= _PHONE_MIN_DIGITS and other[-10:] == tail:
return Contact.objects.get(pk=row.pk), "phone"
if Contact.postal_address_has_content(postal_address):
line1 = (postal_address.get("line1") or "").strip()
zip_code = (postal_address.get("zip") or "").strip()
candidates = Contact.objects.exclude(postal_address={})
if zip_code:
candidates = candidates.filter(
postal_address__zip__istartswith=zip_code[:5]
)
elif line1:
candidates = candidates.filter(postal_address__line1__iexact=line1)
for row in candidates.iterator():
if addresses_match(postal_address, row.postal_address):
return row, "address"
return None, ""
def _merge_notes(existing: str, addition: str) -> str:
existing = (existing or "").strip()
addition = (addition or "").strip()
if not addition:
return existing
if not existing:
return addition
if addition in existing:
return existing
return f"{existing}\n{addition}".strip()
def upsert_contact(
*,
email: str,
first_name: str = "",
last_name: str = "",
phone: str = "",
postal_address: dict | None = None,
source: str = Contact.Source.CONTACT_FORM,
notes_append: str = "",
) -> tuple[Contact, bool, str]:
"""
Find or create a contact, merging on email / phone / address.
Returns (contact, created, match_reason).
When merged onto a different email, the submitted email is noted in ``notes``.
"""
email_norm = (email or "").strip().lower()
phone = (phone or "").strip()
postal = postal_address if isinstance(postal_address, dict) else {}
has_postal = Contact.postal_address_has_content(postal)
existing, reason = find_matching_contact(
email=email_norm,
phone=phone,
postal_address=postal if has_postal else None,
)
if existing is None:
contact = Contact.objects.create(
email=email_norm or None,
first_name=(first_name or "").strip(),
last_name=(last_name or "").strip(),
phone=phone,
postal_address=postal if has_postal else {},
source=source,
notes=(notes_append or "").strip(),
)
return contact, True, ""
changed: list[str] = []
if first_name and first_name.strip():
existing.first_name = first_name.strip()
changed.append("first_name")
if last_name is not None and str(last_name).strip() != "":
existing.last_name = last_name.strip()
changed.append("last_name")
if phone:
existing.phone = phone
changed.append("phone")
if has_postal:
existing.postal_address = postal
changed.append("postal_address")
existing_email = (existing.email or "").strip().lower()
if email_norm and email_norm != existing_email:
if not existing_email:
taken = (
Contact.objects.filter(email__iexact=email_norm)
.exclude(pk=existing.pk)
.exists()
)
if not taken:
existing.email = email_norm
changed.append("email")
else:
existing.notes = _merge_notes(
existing.notes,
f"Alternate email from form (owned elsewhere): {email_norm}",
)
changed.append("notes")
else:
existing.notes = _merge_notes(
existing.notes,
f"Alternate email from form: {email_norm}",
)
changed.append("notes")
if notes_append:
existing.notes = _merge_notes(existing.notes, notes_append)
changed.append("notes")
if source and existing.source == Contact.Source.OTHER:
existing.source = source
changed.append("source")
if changed:
fields = sorted(set(changed) | {"updated_at"})
existing.save(update_fields=fields)
return existing, False, reason
+1 -1
View File
@@ -74,7 +74,7 @@
<div class="field"> <div class="field">
<label class="check-row"><input type="checkbox" name="consent_postcard" value="1" {% if prefs.postcard %}checked{% endif %}> Postcard mailings</label> <label class="check-row"><input type="checkbox" name="consent_postcard" value="1" {% if prefs.postcard %}checked{% endif %}> Postcard mailings</label>
</div> </div>
<p class="hint-block" style="margin-top:16px">Postcard campaigns need a street address and postcard consent. Opt-outs also write a suppression so campaigns skip this contact.</p> <p class="hint-block" style="margin-top:16px">Postcard mailings default on when a street address is on file. Uncheck to opt out. Opt-outs also write a suppression so campaigns skip this contact.</p>
</div> </div>
</div> </div>
</div> </div>
+154
View File
@@ -0,0 +1,154 @@
from django.test import Client, TestCase
from django.urls import reverse
from contacts.models import Contact
from contacts.services import (
addresses_match,
find_matching_contact,
phones_match,
upsert_contact,
)
from leads.models import Lead
class ContactMatchHelpersTests(TestCase):
def test_phones_match_ignores_formatting(self):
self.assertTrue(phones_match("(630) 452-4443", "+1-630-452-4443"))
self.assertFalse(phones_match("6304524443", "6304524444"))
self.assertFalse(phones_match("4524443", "6304524443")) # too short
def test_addresses_match_by_line1_and_zip(self):
a = Contact.make_postal_address(
line1="123 Main St.",
city="Naperville",
state="IL",
zip_code="60540-1234",
)
b = Contact.make_postal_address(
line1="123 Main St",
city="Elsewhere",
state="IL",
zip_code="60540",
)
self.assertTrue(addresses_match(a, b))
def test_find_by_phone_then_address(self):
existing = Contact.objects.create(
email="one@example.com",
phone="6305551212",
first_name="Pat",
postal_address=Contact.make_postal_address(
line1="9 Oak Ave",
city="Wheaton",
state="IL",
zip_code="60187",
),
)
by_phone, reason = find_matching_contact(
email="other@example.com",
phone="(630) 555-1212",
)
self.assertEqual(by_phone, existing)
self.assertEqual(reason, "phone")
by_addr, reason = find_matching_contact(
email="third@example.com",
phone="9995550000",
postal_address=Contact.make_postal_address(
line1="9 Oak Ave",
city="Wheaton",
state="IL",
zip_code="60187",
),
)
self.assertEqual(by_addr, existing)
self.assertEqual(reason, "address")
class UpsertContactMergeTests(TestCase):
def test_merge_by_phone_keeps_one_contact(self):
original = Contact.objects.create(
email="ryan@example.com",
phone="6305559999",
first_name="Ryan",
)
contact, created, reason = upsert_contact(
email="ryan.alt@example.com",
first_name="Ryan",
last_name="Westfall",
phone="630-555-9999",
)
self.assertFalse(created)
self.assertEqual(reason, "phone")
self.assertEqual(contact.pk, original.pk)
self.assertEqual(Contact.objects.count(), 1)
contact.refresh_from_db()
self.assertEqual(contact.email, "ryan@example.com")
self.assertIn("ryan.alt@example.com", contact.notes)
self.assertEqual(contact.last_name, "Westfall")
def test_merge_by_address(self):
original = Contact.objects.create(
email="home@example.com",
first_name="Sam",
postal_address=Contact.make_postal_address(
line1="100 Lake St",
city="Naperville",
state="IL",
zip_code="60540",
),
)
contact, created, reason = upsert_contact(
email="new@example.com",
first_name="Sam",
postal_address=Contact.make_postal_address(
line1="100 Lake St",
city="Naperville",
state="IL",
zip_code="60540",
),
)
self.assertFalse(created)
self.assertEqual(reason, "address")
self.assertEqual(contact.pk, original.pk)
self.assertEqual(Contact.objects.count(), 1)
class ContactFormMergeTests(TestCase):
def setUp(self):
self.client = Client()
self.existing = Contact.objects.create(
email="primary@example.com",
phone="6301112222",
first_name="Alex",
postal_address=Contact.make_postal_address(
line1="55 River Rd",
city="Aurora",
state="IL",
zip_code="60505",
),
)
def test_contact_form_merges_on_phone(self):
url = reverse("public:contact")
response = self.client.post(
url,
{
"first_name": "Alex",
"last_name": "Lee",
"email": "alt@example.com",
"phone": "(630) 111-2222",
"address_line1": "55 River Rd",
"address_city": "Aurora",
"address_state": "IL",
"address_zip": "60505",
"interest": "buying",
"message": "Looking to buy",
},
)
self.assertEqual(response.status_code, 302)
self.assertEqual(Contact.objects.count(), 1)
lead = Lead.objects.get()
self.assertEqual(lead.contact_id, self.existing.pk)
self.assertIn("alt@example.com", lead.message)
self.assertIn("merged by phone", lead.message)
+8 -1
View File
@@ -1,6 +1,6 @@
from django.contrib import admin from django.contrib import admin
from messaging.models import Campaign, Message, MessageTemplate, ProviderEvent from messaging.models import Campaign, Message, MessageTemplate, ProviderEvent, StoredFile
@admin.register(MessageTemplate) @admin.register(MessageTemplate)
@@ -38,3 +38,10 @@ class MessageAdmin(admin.ModelAdmin):
@admin.register(ProviderEvent) @admin.register(ProviderEvent)
class ProviderEventAdmin(admin.ModelAdmin): class ProviderEventAdmin(admin.ModelAdmin):
list_display = ("provider", "event_type", "created_at") list_display = ("provider", "event_type", "created_at")
@admin.register(StoredFile)
class StoredFileAdmin(admin.ModelAdmin):
list_display = ("filename", "kind", "content_type", "size", "created_at")
list_filter = ("kind", "content_type")
readonly_fields = ("size", "content_type", "created_at", "updated_at")
@@ -0,0 +1,34 @@
# Generated by Django 6.1 on 2026-08-09 13:02
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('messaging', '0003_campaign_notify_sent_at'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='StoredFile',
fields=[
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('kind', models.CharField(choices=[('campaign_image', 'Campaign image')], default='campaign_image', max_length=32)),
('filename', models.CharField(blank=True, max_length=255)),
('content_type', models.CharField(max_length=128)),
('size', models.PositiveIntegerField(default=0)),
('data', models.BinaryField()),
('uploaded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='uploaded_files', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-created_at'],
},
),
]
+28
View File
@@ -113,3 +113,31 @@ class ProviderEvent(TimeStampedModel):
provider = models.CharField(max_length=64) provider = models.CharField(max_length=64)
event_type = models.CharField(max_length=64) event_type = models.CharField(max_length=64)
payload = models.JSONField(default=dict, blank=True) payload = models.JSONField(default=dict, blank=True)
class StoredFile(UUIDPrimaryKeyModel, TimeStampedModel):
"""Binary file blob in the database (no filesystem media storage)."""
class Kind(models.TextChoices):
CAMPAIGN_IMAGE = "campaign_image", "Campaign image"
kind = models.CharField(
max_length=32, choices=Kind.choices, default=Kind.CAMPAIGN_IMAGE
)
filename = models.CharField(max_length=255, blank=True)
content_type = models.CharField(max_length=128)
size = models.PositiveIntegerField(default=0)
data = models.BinaryField()
uploaded_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="uploaded_files",
)
class Meta:
ordering = ["-created_at"]
def __str__(self) -> str:
return self.filename or str(self.pk)
+7 -3
View File
@@ -6,7 +6,11 @@ from django.template.loader import get_template
from contacts.models import Channel from contacts.models import Channel
from messaging.services import one_click_unsubscribe_url, preferences_url from messaging.services import one_click_unsubscribe_url, preferences_url
from public.email_branding import email_brand_context, plain_text_to_email_html from public.email_branding import (
campaign_body_to_email_html,
campaign_body_to_plain_text,
email_brand_context,
)
# Reported back on SMTP2GO webhooks when this header is selected in webhook settings. # Reported back on SMTP2GO webhooks when this header is selected in webhook settings.
MONICA_MESSAGE_HEADER = "X-Monica-Message-Id" MONICA_MESSAGE_HEADER = "X-Monica-Message-Id"
@@ -33,8 +37,8 @@ def send_email(message) -> str:
ctx = email_brand_context( ctx = email_brand_context(
title=subject, title=subject,
content=body, content=campaign_body_to_plain_text(body),
content_html=plain_text_to_email_html(body), content_html=campaign_body_to_email_html(body),
prefs_url=prefs_url, prefs_url=prefs_url,
one_click_url=one_click_url, one_click_url=one_click_url,
) )
+37 -8
View File
@@ -32,14 +32,29 @@ def contact_may_receive(contact: Contact, channel: str) -> bool:
).exists(): ).exists():
return False return False
consent = ConsentRecord.objects.filter(contact=contact, channel=channel).first() consent = ConsentRecord.objects.filter(contact=contact, channel=channel).first()
if channel == Channel.POSTCARD:
# Address on file defaults to postcard-eligible until explicit opt-out.
if consent is None:
return Contact.postal_address_has_content(contact.postal_address)
return bool(consent.opted_in)
return bool(consent and consent.opted_in) return bool(consent and consent.opted_in)
def channel_preferences(contact: Contact) -> dict[str, bool]: def channel_preferences(contact: Contact) -> dict[str, bool]:
"""Current opt-in flags for every channel (missing record = False).""" """Current opt-in flags for every channel (missing record = False).
Postcard: missing consent + postal address → shown as opted in (default).
"""
flags = {c.value: False for c in Channel} flags = {c.value: False for c in Channel}
seen: set[str] = set()
for record in contact.consents.all(): for record in contact.consents.all():
flags[record.channel] = record.opted_in flags[record.channel] = record.opted_in
seen.add(record.channel)
if (
Channel.POSTCARD not in seen
and Contact.postal_address_has_content(contact.postal_address)
):
flags[Channel.POSTCARD] = True
return flags return flags
@@ -165,6 +180,20 @@ def opted_in_contacts(channel: str) -> QuerySet[Contact]:
suppressed = Suppression.objects.filter( suppressed = Suppression.objects.filter(
channel=channel, active=True channel=channel, active=True
).values_list("contact_id", flat=True) ).values_list("contact_id", flat=True)
if channel == Channel.POSTCARD:
# Explicit opt-in, or address on file with no postcard consent row yet.
with_address = Contact.objects.filter(
postal_address__has_key="line1",
).exclude(postal_address__line1="")
explicit = with_address.filter(
consents__channel=Channel.POSTCARD,
consents__opted_in=True,
)
implicit = with_address.exclude(consents__channel=Channel.POSTCARD)
qs = (explicit | implicit).exclude(pk__in=suppressed).distinct()
return qs.order_by("first_name", "last_name", "email")
qs = ( qs = (
Contact.objects.filter( Contact.objects.filter(
consents__channel=channel, consents__channel=channel,
@@ -174,10 +203,6 @@ def opted_in_contacts(channel: str) -> QuerySet[Contact]:
.distinct() .distinct()
.order_by("first_name", "last_name", "email") .order_by("first_name", "last_name", "email")
) )
if channel == Channel.POSTCARD:
qs = qs.filter(postal_address__has_key="line1").exclude(
postal_address__line1=""
)
return qs return qs
@@ -399,7 +424,11 @@ def send_campaign_test_email(campaign: Campaign, to_email: str) -> None:
from django.core.mail import EmailMultiAlternatives from django.core.mail import EmailMultiAlternatives
from django.template.loader import get_template from django.template.loader import get_template
from public.email_branding import email_brand_context, plain_text_to_email_html from public.email_branding import (
campaign_body_to_email_html,
campaign_body_to_plain_text,
email_brand_context,
)
if campaign.channel != Channel.EMAIL: if campaign.channel != Channel.EMAIL:
raise ValueError("Test send is only available for email campaigns.") raise ValueError("Test send is only available for email campaigns.")
@@ -420,9 +449,9 @@ def send_campaign_test_email(campaign: Campaign, to_email: str) -> None:
) )
ctx = email_brand_context( ctx = email_brand_context(
title=f"[TEST] {subject}", title=f"[TEST] {subject}",
content=f"{body}\n\n{notice}", content=f"{campaign_body_to_plain_text(body)}\n\n{notice}",
content_html=( content_html=(
f"{plain_text_to_email_html(body)}" f"{campaign_body_to_email_html(body)}"
f'<p style="margin:24px 0 0;color:#6b7280;font-size:13px;">{notice}</p>' f'<p style="margin:24px 0 0;color:#6b7280;font-size:13px;">{notice}</p>'
), ),
) )
@@ -108,7 +108,7 @@
<td>{% if event.message %}{{ event.message.contact }}{% else %}—{% endif %}</td> <td>{% if event.message %}{{ event.message.contact }}{% else %}—{% endif %}</td>
</tr> </tr>
{% empty %} {% empty %}
<tr><td colspan="3" class="empty-state">No provider events yet. Configure the SMTP2GO webhook after first send.</td></tr> <tr><td colspan="3" class="empty-state">No webhook events yet. SMTP2GO must POST opens/clicks to <code>/portal/messaging/webhooks/email/</code> (see messaging README). SMTP2GOs own “Clicked” feed does not fill this table by itself.</td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
@@ -126,7 +126,12 @@
<tbody id="recipients-body"> <tbody id="recipients-body">
{% for message in messages %} {% for message in messages %}
<tr data-message-id="{{ message.pk }}"> <tr data-message-id="{{ message.pk }}">
<td>{{ message.contact }}</td> <td>
<div>{{ message.contact }}</div>
{% if message.destination %}
<div class="muted" style="font-size:12px;margin-top:2px">{{ message.destination }}</div>
{% endif %}
</td>
<td><span class="badge badge-{{ message.status }}">{{ message.get_status_display }}</span></td> <td><span class="badge badge-{{ message.status }}">{{ message.get_status_display }}</span></td>
<td class="muted">{{ message.provider_message_id|default:"—" }}</td> <td class="muted">{{ message.provider_message_id|default:"—" }}</td>
<td class="muted">{{ message.error|truncatechars:60|default:"—" }}</td> <td class="muted">{{ message.error|truncatechars:60|default:"—" }}</td>
@@ -165,8 +170,11 @@
body.innerHTML = '<tr><td colspan="4" class="empty-state">No messages on this campaign.</td></tr>'; body.innerHTML = '<tr><td colspan="4" class="empty-state">No messages on this campaign.</td></tr>';
} else { } else {
body.innerHTML = data.messages.map(function (m) { body.innerHTML = data.messages.map(function (m) {
var dest = m.destination
? '<div class="muted" style="font-size:12px;margin-top:2px">' + esc(m.destination) + '</div>'
: '';
return "<tr data-message-id=\"" + esc(m.id) + "\">" + return "<tr data-message-id=\"" + esc(m.id) + "\">" +
"<td>" + esc(m.contact) + "</td>" + "<td><div>" + esc(m.contact) + "</div>" + dest + "</td>" +
"<td><span class=\"badge badge-" + esc(m.status) + "\">" + esc(m.status_display) + "</span></td>" + "<td><span class=\"badge badge-" + esc(m.status) + "\">" + esc(m.status_display) + "</span></td>" +
"<td class=\"muted\">" + esc(m.provider_message_id || "—") + "</td>" + "<td class=\"muted\">" + esc(m.provider_message_id || "—") + "</td>" +
"<td class=\"muted\">" + esc(m.error || "—") + "</td></tr>"; "<td class=\"muted\">" + esc(m.error || "—") + "</td></tr>";
@@ -1,11 +1,22 @@
{% extends "portal_base.html" %} {% extends "portal_base.html" %}
{% block title %}Campaigns · Portal{% endblock %} {% block title %}Campaigns · Portal{% endblock %}
{% block topbar_title %}Campaign composer{% endblock %} {% block topbar_title %}Campaign composer{% endblock %}
{% 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; }
#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; }
</style>
{% endblock %}
{% block portal_content %} {% block portal_content %}
<div class="channel-tabs"> <div class="channel-tabs" id="compose-channel-tabs">
<a class="active" href="#compose-email">Email</a> <a class="active" href="#compose-email" data-channel="email">Email</a>
<a href="#compose-sms">SMS</a> <a href="#compose-sms" data-channel="sms">SMS</a>
<a href="{% url 'messaging:postcard_designer' %}">Postcard</a> <a href="{% url 'messaging:postcard_designer' %}" data-channel="postcard">Postcard</a>
</div> </div>
<div class="split"> <div class="split">
@@ -26,7 +37,7 @@
<input id="id_name" name="name" type="text" required <input id="id_name" name="name" type="text" required
placeholder="Spring seller tips" value="{{ form_data.name }}"> placeholder="Spring seller tips" value="{{ form_data.name }}">
</div> </div>
<div class="field"> <div class="field" id="subject-field">
<label for="id_subject">Subject</label> <label for="id_subject">Subject</label>
<input id="id_subject" name="subject" type="text" <input id="id_subject" name="subject" type="text"
placeholder="A quick tip for sellers this week" placeholder="A quick tip for sellers this week"
@@ -34,26 +45,32 @@
oninput="syncCampaignPreview()"> oninput="syncCampaignPreview()">
<div class="hint">Email only — ignored for SMS / postcard</div> <div class="hint">Email only — ignored for SMS / postcard</div>
</div> </div>
<div class="field"> <div class="field" id="email-editor-wrap">
<label for="id_body">Body</label> <label>Body</label>
<textarea id="id_body" name="body" style="min-height:140px" <div id="email-editor"></div>
<textarea id="id_body" name="body" hidden>{{ form_data.body }}</textarea>
<div class="hint">Bold, fonts, sizes, links, images · merge tags: {first_name}, {last_name}</div>
</div>
<div class="field" id="sms-body-wrap" hidden>
<label for="id_body_sms">Body</label>
<textarea id="id_body_sms" style="min-height:140px"
placeholder="Hi {first_name}, …" placeholder="Hi {first_name}, …"
oninput="syncCampaignPreview()">{{ form_data.body }}</textarea> oninput="syncSmsBody()">{{ form_data.body }}</textarea>
<div class="hint">Merge tags: first_name, last_name, unsubscribe_url · optional for postcard</div> <div class="hint">Plain text for SMS · keep it short</div>
</div> </div>
<div class="field" id="postcard-template-field"> <div class="field" id="postcard-template-field">
<label for="id_template_id">Postcard template</label> <label for="id_template_id">Postcard design</label>
<select id="id_template_id" name="template_id"> <select id="id_template_id" name="template_id">
<option value="">— Select saved design —</option> <option value="">— Select a PCM design —</option>
{% for t in postcard_templates %} {% for d in postcard_designs %}
<option value="{{ t.pk }}"{% if form_data.template_id == t.pk|stringformat:"s" %} selected{% endif %}> <option value="{{ d.value }}"{% if form_data.template_id == d.value %} selected{% endif %}>
{{ t.name }} (design {{ t.postcard_front.design_id }}) {{ d.label }}
</option> </option>
{% empty %} {% empty %}
<option value="" disabled>No templates yet — use Postcard designer</option> <option value="" disabled>No designs yet — open Postcard design</option>
{% endfor %} {% endfor %}
</select> </select>
<div class="hint"><a href="{% url 'messaging:postcard_designer' %}">Open postcard designer</a></div> <div class="hint"><a href="{% url 'messaging:postcard_designer' %}">Open postcard designer</a> to create or edit designs</div>
</div> </div>
<div class="form-grid cols-2"> <div class="form-grid cols-2">
<div class="field"> <div class="field">
@@ -83,7 +100,7 @@
<div class="muted" id="preview-empty">Preview updates as you type.</div> <div class="muted" id="preview-empty">Preview updates as you type.</div>
<div id="preview-content" hidden> <div id="preview-content" hidden>
<div class="hint" id="preview-subject"></div> <div class="hint" id="preview-subject"></div>
<div id="preview-body" style="white-space:pre-wrap;margin-top:8px"></div> <div id="preview-body" style="margin-top:8px"></div>
</div> </div>
</div> </div>
<p class="hint-block">After send → open the campaign report for delivery &amp; engagement.</p> <p class="hint-block">After send → open the campaign report for delivery &amp; engagement.</p>
@@ -122,10 +139,43 @@
</div> </div>
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
<script src="https://cdn.jsdelivr.net/npm/quill@2.0.3/dist/quill.js"></script>
<script> <script>
function syncCampaignPreview() { (function () {
var uploadUrl = "{{ image_upload_url|escapejs }}";
var csrfToken = (document.querySelector('#campaign-compose [name=csrfmiddlewaretoken]') || {}).value || '';
var bodyField = document.getElementById('id_body');
var smsField = document.getElementById('id_body_sms');
var quill = null;
function csrfHeader() {
return { 'X-CSRFToken': csrfToken };
}
function syncBodyFromQuill() {
if (!quill || !bodyField) return;
var html = quill.root.innerHTML;
if (html === '<p><br></p>' || html === '<p></p>') html = '';
bodyField.value = html;
syncCampaignPreview();
}
window.syncSmsBody = function () {
if (smsField && bodyField) bodyField.value = smsField.value;
syncCampaignPreview();
};
window.syncCampaignPreview = function () {
var subject = (document.getElementById('id_subject') || {}).value || ''; var subject = (document.getElementById('id_subject') || {}).value || '';
var body = (document.getElementById('id_body') || {}).value || ''; var audience = (document.getElementById('id_audience') || {}).value || '';
var isEmail = audience === 'email_opt_in';
var body = '';
if (isEmail && quill) {
body = quill.root.innerHTML;
if (body === '<p><br></p>' || body === '<p></p>') body = '';
} else {
body = (bodyField && bodyField.value) || '';
}
var empty = document.getElementById('preview-empty'); var empty = document.getElementById('preview-empty');
var content = document.getElementById('preview-content'); var content = document.getElementById('preview-content');
var subEl = document.getElementById('preview-subject'); var subEl = document.getElementById('preview-subject');
@@ -139,20 +189,122 @@ function syncCampaignPreview() {
empty.hidden = true; empty.hidden = true;
content.hidden = false; content.hidden = false;
subEl.textContent = subject ? ('Subject: ' + subject) : ''; subEl.textContent = subject ? ('Subject: ' + subject) : '';
if (isEmail) {
bodyEl.style.whiteSpace = 'normal';
bodyEl.innerHTML = body;
} else {
bodyEl.style.whiteSpace = 'pre-wrap';
bodyEl.textContent = body; bodyEl.textContent = body;
} }
function syncComposeChannel() { };
window.syncComposeChannel = function () {
var audience = (document.getElementById('id_audience') || {}).value || ''; var audience = (document.getElementById('id_audience') || {}).value || '';
var isPostcard = audience === 'postcard_opt_in'; var isPostcard = audience === 'postcard_opt_in';
var isSms = audience === 'sms_opt_in';
var isEmail = audience === 'email_opt_in';
var tmplField = document.getElementById('postcard-template-field'); var tmplField = document.getElementById('postcard-template-field');
var body = document.getElementById('id_body'); var emailWrap = document.getElementById('email-editor-wrap');
var smsWrap = document.getElementById('sms-body-wrap');
var subjectField = document.getElementById('subject-field');
if (tmplField) tmplField.style.display = isPostcard ? '' : 'none'; if (tmplField) tmplField.style.display = isPostcard ? '' : 'none';
if (body) { if (subjectField) subjectField.style.display = isEmail ? '' : 'none';
if (isPostcard) body.removeAttribute('required'); if (emailWrap) emailWrap.hidden = !isEmail;
else body.setAttribute('required', 'required'); if (smsWrap) smsWrap.hidden = isEmail;
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) {
smsField.value = bodyField.value;
} }
} bodyField.value = smsField ? smsField.value : bodyField.value;
syncCampaignPreview(); bodyField.removeAttribute('required');
syncComposeChannel(); }
document.querySelectorAll('#compose-channel-tabs a[data-channel]').forEach(function (a) {
var ch = a.getAttribute('data-channel');
var active = (ch === 'email' && isEmail) || (ch === 'sms' && isSms) || (ch === 'postcard' && isPostcard);
a.classList.toggle('active', active);
});
syncCampaignPreview();
};
function initQuill() {
var initial = (bodyField && bodyField.value) || '';
quill = new Quill('#email-editor', {
theme: 'snow',
placeholder: 'Hi {first_name}, …',
modules: {
toolbar: {
container: [
[{ font: [] }, { size: ['small', false, 'large', 'huge'] }],
['bold', 'italic', 'underline'],
[{ color: [] }, { background: [] }],
[{ list: 'ordered' }, { list: 'bullet' }],
['link', 'image'],
['clean']
],
handlers: {
image: function () {
var input = document.createElement('input');
input.setAttribute('type', 'file');
input.setAttribute('accept', 'image/png,image/jpeg,image/gif,image/webp');
input.click();
input.onchange = function () {
var file = input.files && input.files[0];
if (!file) return;
var data = new FormData();
data.append('image', file);
fetch(uploadUrl, {
method: 'POST',
headers: csrfHeader(),
body: data,
credentials: 'same-origin'
}).then(function (res) {
return res.json().then(function (json) {
if (!res.ok) throw new Error(json.error || 'Upload failed');
return json;
});
}).then(function (json) {
var range = quill.getSelection(true);
quill.insertEmbed(range.index, 'image', json.url, 'user');
quill.setSelection(range.index + 1);
syncBodyFromQuill();
}).catch(function (err) {
alert(err.message || 'Image upload failed');
});
};
}
}
}
}
});
if (initial && initial.indexOf('<') !== -1) {
quill.root.innerHTML = initial;
} else if (initial) {
quill.setText(initial);
}
quill.on('text-change', syncBodyFromQuill);
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;
});
}
document.querySelectorAll('#compose-channel-tabs a[data-channel="email"], #compose-channel-tabs a[data-channel="sms"]').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';
syncComposeChannel();
});
});
initQuill();
syncComposeChannel();
})();
</script> </script>
{% endblock %} {% endblock %}
@@ -81,7 +81,8 @@
<button class="btn btn-primary" type="submit">Save as postcard template</button> <button class="btn btn-primary" type="submit">Save as postcard template</button>
</form> </form>
<p class="library-hint" style="margin-top:12px"> <p class="library-hint" style="margin-top:12px">
Saved templates appear when composing a postcard campaign. Designs listed above are selectable in Campaigns when Recipients is postcard.
Saving as a template keeps a named local copy.
</p> </p>
</div> </div>
</div> </div>
+142
View File
@@ -5,9 +5,11 @@ from django.urls import reverse
from contacts.models import Channel, ConsentRecord, Contact, Suppression from contacts.models import Channel, ConsentRecord, Contact, Suppression
from messaging.models import Campaign, Message, MessageTemplate, ProviderEvent from messaging.models import Campaign, Message, MessageTemplate, ProviderEvent
from messaging.services import ( from messaging.services import (
channel_preferences,
contact_may_receive, contact_may_receive,
create_campaign_draft, create_campaign_draft,
make_unsubscribe_token, make_unsubscribe_token,
opted_in_contacts,
set_channel_consent, set_channel_consent,
) )
@@ -757,3 +759,143 @@ class PcmAuthTests(TestCase):
with self.assertRaises(pcm_mod.PcmApiError) as ctx: with self.assertRaises(pcm_mod.PcmApiError) as ctx:
pcm_mod.login(force=True) pcm_mod.login(force=True)
self.assertIn("PCM_API_SECRET", str(ctx.exception)) self.assertIn("PCM_API_SECRET", str(ctx.exception))
class PostcardAddressDefaultConsentTests(TestCase):
def test_address_without_consent_is_postcard_eligible(self):
contact = Contact.objects.create(
email="addr@example.com",
first_name="Ann",
postal_address=Contact.make_postal_address(
line1="1 Oak St",
city="Naperville",
state="IL",
zip_code="60540",
),
)
self.assertTrue(contact_may_receive(contact, Channel.POSTCARD))
prefs = channel_preferences(contact)
self.assertTrue(prefs[Channel.POSTCARD])
self.assertEqual(opted_in_contacts(Channel.POSTCARD).count(), 1)
def test_explicit_postcard_opt_out_respected(self):
contact = Contact.objects.create(
email="out@example.com",
postal_address=Contact.make_postal_address(line1="2 Oak St"),
)
set_channel_consent(
contact, Channel.POSTCARD, opted_in=False, reason="opt_out"
)
self.assertFalse(contact_may_receive(contact, Channel.POSTCARD))
self.assertEqual(opted_in_contacts(Channel.POSTCARD).count(), 0)
class CampaignHtmlBodyTests(TestCase):
def test_html_body_preserved_in_email(self):
from django.core import mail
User = get_user_model()
user = User.objects.create_user(
username="htmlsender", password="test-pass-123", email="s@example.com"
)
contact = Contact.objects.create(email="pat@example.com", first_name="Pat")
set_channel_consent(contact, Channel.EMAIL, opted_in=True, reason="test")
campaign = create_campaign_draft(
name="HTML tip",
audience=Campaign.Audience.EMAIL_OPT_IN,
subject="Styled",
body='<p><strong>Hello</strong> <img src="https://example.com/a.png"></p>',
created_by=user,
)
from messaging.providers.email.smtp2go import send_email
send_email(campaign.messages.get())
self.assertEqual(len(mail.outbox), 1)
html = mail.outbox[0].alternatives[0][0]
self.assertIn("<strong>Hello</strong>", html)
self.assertIn('src="https://example.com/a.png"', html)
class PostcardDesignPickTests(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user(
username="pcm-pick", password="test-pass-123"
)
self.client = Client()
self.client.login(username="pcm-pick", password="test-pass-123")
self.contact = Contact.objects.create(
email="mail@example.com",
postal_address=Contact.make_postal_address(line1="9 Main"),
)
def test_compose_can_pick_pcm_design_id(self):
from unittest.mock import patch
with patch(
"messaging.views._fetch_pcm_designs",
return_value=(
[{"design_id": "42", "name": "Spring card", "size": "46"}],
"",
),
):
url = reverse("messaging:campaign_list")
response = self.client.post(
url,
{
"name": "Mailer",
"subject": "",
"body": "",
"audience": Campaign.Audience.POSTCARD_OPT_IN,
"template_id": "d:42",
},
)
campaign = Campaign.objects.get(name="Mailer")
self.assertEqual(response.status_code, 302)
self.assertEqual(campaign.channel, Channel.POSTCARD)
self.assertIsNotNone(campaign.template_id)
self.assertEqual(
campaign.template.postcard_front.get("design_id"), 42
)
self.assertEqual(campaign.messages.count(), 1)
class StoredFileUploadTests(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user(
username="uploader", password="test-pass-123"
)
self.client = Client()
self.client.login(username="uploader", password="test-pass-123")
def test_upload_stores_bytes_in_database(self):
from django.core.files.uploadedfile import SimpleUploadedFile
from messaging.models import StoredFile
png = (
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01"
b"\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde\x00\x00"
b"\x00\x0cIDATx\x9cc\xf8\x0f\x00\x00\x01\x01\x00\x05\x18"
b"\xd8N\x00\x00\x00\x00IEND\xaeB`\x82"
)
upload = SimpleUploadedFile("dot.png", png, content_type="image/png")
response = self.client.post(
reverse("messaging:campaign_image_upload"),
{"image": upload},
)
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertIn("url", payload)
stored = StoredFile.objects.get()
self.assertEqual(bytes(stored.data), png)
self.assertEqual(stored.content_type, "image/png")
self.assertEqual(stored.uploaded_by, self.user)
fetch = self.client.get(
reverse("messaging:stored_file", kwargs={"pk": stored.pk})
)
self.assertEqual(fetch.status_code, 200)
self.assertEqual(fetch["Content-Type"], "image/png")
self.assertEqual(b"".join(fetch.streaming_content), png)
+10
View File
@@ -18,6 +18,16 @@ urlpatterns = [
views.campaign_test_send, views.campaign_test_send,
name="campaign_test_send", name="campaign_test_send",
), ),
path(
"campaigns/upload-image/",
views.campaign_image_upload,
name="campaign_image_upload",
),
path(
"files/<uuid:pk>/",
views.stored_file,
name="stored_file",
),
path("postcard/", views.postcard_designer, name="postcard_designer"), path("postcard/", views.postcard_designer, name="postcard_designer"),
path( path(
"postcard/create/", "postcard/create/",
+219 -53
View File
@@ -1,5 +1,6 @@
import hashlib import hashlib
import hmac import hmac
import io
import logging import logging
from django.conf import settings from django.conf import settings
@@ -7,18 +8,19 @@ from django.contrib import messages
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import validate_email from django.core.validators import validate_email
from django.http import HttpResponseForbidden, JsonResponse from django.http import FileResponse, HttpResponseForbidden, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse from django.urls import reverse
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_GET, require_http_methods, require_POST from django.views.decorators.http import require_GET, require_http_methods, require_POST
from contacts.models import Channel from contacts.models import Channel
from messaging.models import Campaign, MessageTemplate, ProviderEvent from messaging.models import Campaign, MessageTemplate, ProviderEvent, StoredFile
from messaging.providers.postcard.pcm import ( from messaging.providers.postcard.pcm import (
PCM_SIZE_CHOICES, PCM_SIZE_CHOICES,
PcmApiError, PcmApiError,
create_custom_design, create_custom_design,
design_id_from_template,
get_design_embed_url, get_design_embed_url,
list_designs, list_designs,
) )
@@ -41,6 +43,11 @@ from messaging.webhooks import (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_ALLOWED_IMAGE_TYPES = frozenset(
{"image/jpeg", "image/png", "image/gif", "image/webp"}
)
_MAX_IMAGE_BYTES = 5 * 1024 * 1024
def _audience_choices() -> list[tuple[str, str]]: def _audience_choices() -> list[tuple[str, str]]:
"""Labeled audience options with live opted-in counts.""" """Labeled audience options with live opted-in counts."""
@@ -65,8 +72,165 @@ def _postcard_templates():
)[:50] )[:50]
def _fetch_pcm_designs() -> tuple[list[dict], str]:
"""Return (normalized design rows, api_error)."""
designs: list[dict] = []
api_error = ""
try:
remote = list_designs(product_type="postcard")
for item in remote:
if not isinstance(item, dict):
continue
did = item.get("designID") or item.get("design_id") or item.get("id")
if did is None:
continue
size_info = item.get("size") or {}
size_key = (
size_info.get("key")
if isinstance(size_info, dict)
else size_info
) or ""
designs.append(
{
"design_id": str(did),
"name": item.get("friendlyName")
or item.get("name")
or f"Design {did}",
"size": str(size_key),
}
)
except PcmApiError as exc:
api_error = str(exc)
seen = {d["design_id"] for d in designs}
for tmpl in _postcard_templates():
front = tmpl.postcard_front or {}
did = front.get("design_id")
if did is None:
continue
did_s = str(did)
if did_s in seen:
continue
designs.insert(
0,
{
"design_id": did_s,
"name": tmpl.name,
"size": str(front.get("size") or ""),
},
)
seen.add(did_s)
return designs, api_error
def _postcard_design_choices() -> list[dict]:
"""Options for campaign compose: PCM designs + saved templates."""
designs, _ = _fetch_pcm_designs()
by_id = {d["design_id"]: d for d in designs}
choices: list[dict] = []
for tmpl in _postcard_templates():
did = design_id_from_template(tmpl)
if did is None:
continue
did_s = str(did)
choices.append(
{
"value": f"t:{tmpl.pk}",
"label": f"{tmpl.name} (design {did_s})",
"design_id": did_s,
}
)
by_id.pop(did_s, None)
for did_s, d in by_id.items():
choices.append(
{
"value": f"d:{did_s}",
"label": f"{d['name']} (design {did_s})",
"design_id": did_s,
"size": d.get("size") or "46",
"name": d["name"],
}
)
return choices
def _resolve_postcard_template(raw: str) -> MessageTemplate | None:
"""Resolve compose select value ``t:<uuid>`` or ``d:<design_id>``."""
value = (raw or "").strip()
if not value:
return None
if value.startswith("t:"):
return MessageTemplate.objects.filter(
pk=value[2:], channel=Channel.POSTCARD
).first()
if value.startswith("d:"):
design_raw = value[2:].strip()
try:
design_id = int(design_raw)
except ValueError:
return None
for tmpl in MessageTemplate.objects.filter(channel=Channel.POSTCARD):
if design_id_from_template(tmpl) == design_id:
return tmpl
name = f"PCM design {design_id}"
designs, _ = _fetch_pcm_designs()
match = next(
(d for d in designs if d["design_id"] == str(design_id)), None
)
size = (match or {}).get("size") or "46"
if match and match.get("name"):
name = match["name"]
front = {
"design_id": design_id,
"size": size,
"name": name,
"provider": "pcm",
}
return MessageTemplate.objects.create(
channel=Channel.POSTCARD,
name=name[:120],
subject="",
body=f"PCM design {design_id}",
postcard_front=front,
postcard_back={},
)
# Legacy: bare MessageTemplate pk
return MessageTemplate.objects.filter(
pk=value, channel=Channel.POSTCARD
).first()
def _format_postal_address(addr: dict | None) -> str:
if not addr:
return ""
line1 = (addr.get("line1") or "").strip()
line2 = (addr.get("line2") or "").strip()
city = (addr.get("city") or "").strip()
state = (addr.get("state") or "").strip()
zip_code = (addr.get("zip") or "").strip()
city_line = ", ".join(p for p in (city, state) if p)
if zip_code:
city_line = f"{city_line} {zip_code}".strip()
return ", ".join(p for p in (line1, line2, city_line) if p)
def _message_destination(message) -> str:
"""Channel-specific destination shown on the recipients table."""
contact = message.contact
channel = message.channel or (message.campaign.channel if message.campaign_id else "")
if channel == Channel.EMAIL:
return (contact.email or "").strip()
if channel == Channel.SMS:
return (contact.phone or "").strip()
if channel == Channel.POSTCARD:
return _format_postal_address(contact.postal_address)
return ""
def _campaign_report(campaign: Campaign) -> dict: def _campaign_report(campaign: Campaign) -> dict:
messages_qs = list(campaign.messages.select_related("contact").all()[:200]) messages_qs = list(campaign.messages.select_related("contact").all()[:200])
for msg in messages_qs:
msg.destination = _message_destination(msg)
stats = campaign_engagement_stats(campaign) stats = campaign_engagement_stats(campaign)
recent_events = ( recent_events = (
ProviderEvent.objects.filter(message__campaign=campaign) ProviderEvent.objects.filter(message__campaign=campaign)
@@ -168,7 +332,7 @@ def campaign_list(request):
template = None template = None
if template_id: if template_id:
template = MessageTemplate.objects.filter(pk=template_id).first() template = _resolve_postcard_template(template_id)
if not name: if not name:
form_errors.append("Campaign name is required.") form_errors.append("Campaign name is required.")
@@ -177,7 +341,7 @@ def campaign_list(request):
if audience == Campaign.Audience.POSTCARD_OPT_IN: if audience == Campaign.Audience.POSTCARD_OPT_IN:
if not template or template.channel != Channel.POSTCARD: if not template or template.channel != Channel.POSTCARD:
form_errors.append( form_errors.append(
"Choose a saved postcard template (design it under Postcard first)." "Choose a postcard design (create one under Postcard design)."
) )
if not body: if not body:
body = "Postcard mailing" body = "Postcard mailing"
@@ -222,9 +386,10 @@ def campaign_list(request):
{ {
"campaigns": campaigns, "campaigns": campaigns,
"audience_choices": _audience_choices(), "audience_choices": _audience_choices(),
"postcard_templates": _postcard_templates(), "postcard_designs": _postcard_design_choices(),
"form_data": form_data, "form_data": form_data,
"form_errors": form_errors, "form_errors": form_errors,
"image_upload_url": reverse("messaging:campaign_image_upload"),
}, },
) )
@@ -274,6 +439,7 @@ def campaign_status_json(request, pk):
{ {
"id": str(m.pk), "id": str(m.pk),
"contact": str(m.contact), "contact": str(m.contact),
"destination": getattr(m, "destination", "") or "",
"status": m.status, "status": m.status,
"status_display": m.get_status_display(), "status_display": m.get_status_display(),
"provider_message_id": m.provider_message_id or "", "provider_message_id": m.provider_message_id or "",
@@ -340,62 +506,62 @@ def campaign_test_send(request, pk):
return redirect("messaging:campaign_detail", pk=campaign.pk) return redirect("messaging:campaign_detail", pk=campaign.pk)
@login_required
@require_POST
def campaign_image_upload(request):
"""Upload an image for the email rich editor; store bytes in the DB."""
upload = request.FILES.get("image") or request.FILES.get("file")
if not upload:
return JsonResponse({"error": "No image uploaded."}, status=400)
content_type = (getattr(upload, "content_type", None) or "").lower()
if content_type not in _ALLOWED_IMAGE_TYPES:
return JsonResponse(
{"error": "Use a JPEG, PNG, GIF, or WebP image."}, status=400
)
if upload.size and upload.size > _MAX_IMAGE_BYTES:
return JsonResponse({"error": "Image must be 5 MB or smaller."}, status=400)
data = upload.read()
if len(data) > _MAX_IMAGE_BYTES:
return JsonResponse({"error": "Image must be 5 MB or smaller."}, status=400)
original = (getattr(upload, "name", None) or "image")[:255]
stored = StoredFile.objects.create(
kind=StoredFile.Kind.CAMPAIGN_IMAGE,
filename=original,
content_type=content_type,
size=len(data),
data=data,
uploaded_by=request.user if request.user.is_authenticated else None,
)
path = reverse("messaging:stored_file", kwargs={"pk": stored.pk})
url = request.build_absolute_uri(path)
return JsonResponse({"url": url, "id": str(stored.pk)})
@require_GET
def stored_file(request, pk):
"""Public fetch for email clients / preview (UUID acts as capability token)."""
stored = get_object_or_404(StoredFile, pk=pk)
response = FileResponse(
io.BytesIO(bytes(stored.data)),
content_type=stored.content_type or "application/octet-stream",
)
if stored.filename:
response["Content-Disposition"] = f'inline; filename="{stored.filename}"'
response["Cache-Control"] = "public, max-age=86400"
return response
@login_required @login_required
def postcard_designer(request): def postcard_designer(request):
"""PCM Integrations designer — list designs + embed iframe.""" """PCM Integrations designer — list designs + embed iframe."""
api_error = "" designs, api_error = _fetch_pcm_designs()
designs: list[dict] = []
embed_url = "" embed_url = ""
active_design_id = (request.GET.get("design_id") or "").strip() active_design_id = (request.GET.get("design_id") or "").strip()
active_name = "" active_name = ""
active_size = "46" active_size = "46"
try:
remote = list_designs(product_type="postcard")
for item in remote:
if not isinstance(item, dict):
continue
did = item.get("designID") or item.get("design_id") or item.get("id")
if did is None:
continue
size_info = item.get("size") or {}
size_key = (
size_info.get("key")
if isinstance(size_info, dict)
else size_info
) or ""
designs.append(
{
"design_id": str(did),
"name": item.get("friendlyName")
or item.get("name")
or f"Design {did}",
"size": str(size_key),
}
)
except PcmApiError as exc:
api_error = str(exc)
# Merge saved local templates that may not appear in the remote page yet.
seen = {d["design_id"] for d in designs}
for tmpl in _postcard_templates():
front = tmpl.postcard_front or {}
did = front.get("design_id")
if did is None:
continue
did_s = str(did)
if did_s in seen:
continue
designs.insert(
0,
{
"design_id": did_s,
"name": tmpl.name,
"size": str(front.get("size") or ""),
},
)
seen.add(did_s)
if active_design_id: if active_design_id:
match = next( match = next(
(d for d in designs if d["design_id"] == active_design_id), None (d for d in designs if d["design_id"] == active_design_id), None
+5
View File
@@ -186,7 +186,12 @@ STATIC_ROOT = BASE_DIR / "staticfiles"
STATICFILES_DIRS = [ STATICFILES_DIRS = [
BASE_DIR / "monica_site" / "static", BASE_DIR / "monica_site" / "static",
] ]
# Uploaded blobs live in the DB (messaging.StoredFile). Default storage is
# in-memory only so nothing is written to disk accidentally.
STORAGES = { STORAGES = {
"default": {
"BACKEND": "django.core.files.storage.memory.InMemoryStorage",
},
"staticfiles": { "staticfiles": {
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage", "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
}, },
+3
View File
@@ -9,6 +9,9 @@ SITE_UNDER_CONSTRUCTION = env_bool("SITE_UNDER_CONSTRUCTION", False) # noqa: F4
TIANJI_ENABLED = env_bool("TIANJI_ENABLED", False) # noqa: F405 TIANJI_ENABLED = env_bool("TIANJI_ENABLED", False) # noqa: F405
STORAGES = { STORAGES = {
"default": {
"BACKEND": "django.core.files.storage.memory.InMemoryStorage",
},
"staticfiles": { "staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage", "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
}, },
+63
View File
@@ -39,6 +39,27 @@ def _absolute_static_url(site_url: str, relative: str) -> str:
return f"{site_url}{path}" return f"{site_url}{path}"
def _tagline_with_site_link(tagline: str, site_url: str) -> str:
"""Turn leading MKDRealtor.com (or similar) into a link to the public site."""
raw = (tagline or "").strip()
if not raw:
return ""
# Match "MKDRealtor.com" (any case) at the start, optional trailing " · rest"
match = re.match(
r"(?i)^(MKDRealtor\.com)(\s*[·•\-–—]\s*.*)?$",
raw,
)
if not match:
return html.escape(raw)
label = html.escape(match.group(1))
rest = html.escape(match.group(2) or "")
href = html.escape(site_url, quote=True)
return (
f'<a href="{href}" style="color:#00626c;text-decoration:none;">{label}</a>'
f"{rest}"
)
def email_brand_context(**extra): def email_brand_context(**extra):
site_url = (getattr(settings, "PUBLIC_SITE_URL", None) or "").rstrip("/") site_url = (getattr(settings, "PUBLIC_SITE_URL", None) or "").rstrip("/")
if not site_url: if not site_url:
@@ -57,6 +78,7 @@ def email_brand_context(**extra):
"brand_name": brand_name, "brand_name": brand_name,
"brand_legal": brand_legal, "brand_legal": brand_legal,
"brand_tagline": tagline, "brand_tagline": tagline,
"brand_tagline_html": _tagline_with_site_link(tagline, site_url),
"host_label": host_label, "host_label": host_label,
**extra, **extra,
} }
@@ -81,3 +103,44 @@ def plain_text_to_email_html(text: str) -> str:
f'line-height:1.6;">{joined}</p>' f'line-height:1.6;">{joined}</p>'
) )
return "\n".join(blocks) return "\n".join(blocks)
_HTML_TAG_RE = re.compile(
r"<\s*(p|div|br|span|strong|em|b|i|u|a|img|h[1-6]|ul|ol|li|font|table)\b",
re.I,
)
def sanitize_email_html(raw: str) -> str:
"""Light cleanup for staff-authored HTML (Quill) before sending."""
text = raw or ""
text = re.sub(r"(?is)<script[^>]*>.*?</script>", "", text)
text = re.sub(r"(?is)<iframe[^>]*>.*?</iframe>", "", text)
text = re.sub(r"(?is)<object[^>]*>.*?</object>", "", text)
text = re.sub(r"(?i)\son\w+\s*=\s*([\"']).*?\1", "", text)
text = re.sub(r"(?i)\son\w+\s*=\s*[^\s>]+", "", text)
text = re.sub(r"(?i)javascript:", "", text)
return text.strip()
def campaign_body_to_email_html(body: str) -> str:
"""Render campaign body for email — HTML as-is when Quill markup, else plain."""
raw = (body or "").strip()
if not raw:
return ""
if _HTML_TAG_RE.search(raw):
return sanitize_email_html(raw)
return plain_text_to_email_html(raw)
def campaign_body_to_plain_text(body: str) -> str:
"""Plain-text alternative for multipart emails."""
from django.utils.html import strip_tags
raw = (body or "").strip()
if not raw:
return ""
if _HTML_TAG_RE.search(raw):
text = strip_tags(sanitize_email_html(raw))
return html.unescape(re.sub(r"[ \t]+\n", "\n", text)).strip()
return raw
+7 -1
View File
@@ -73,7 +73,9 @@
{% if brand_name %} {% if brand_name %}
<p style="margin:12px 0 0;font-size:15px;font-weight:600;color:#212121;">{{ brand_name }}</p> <p style="margin:12px 0 0;font-size:15px;font-weight:600;color:#212121;">{{ brand_name }}</p>
{% endif %} {% endif %}
{% if brand_tagline %} {% if brand_tagline_html %}
<p style="margin:4px 0 0;font-size:12px;color:#6b7280;">{{ brand_tagline_html|safe }}</p>
{% elif brand_tagline %}
<p style="margin:4px 0 0;font-size:12px;color:#6b7280;">{{ brand_tagline }}</p> <p style="margin:4px 0 0;font-size:12px;color:#6b7280;">{{ brand_tagline }}</p>
{% endif %} {% endif %}
{% block header_extra %}{% endblock %} {% block header_extra %}{% endblock %}
@@ -97,10 +99,14 @@
&copy; {% now "Y" %} {{ brand_name|default:"Monica Dhillon" }}. All rights reserved. &copy; {% now "Y" %} {{ brand_name|default:"Monica Dhillon" }}. All rights reserved.
</p> </p>
<p style="margin:0;color:#6b7280;"> <p style="margin:0;color:#6b7280;">
{% if brand_tagline_html %}
{{ brand_tagline_html|safe }}
{% else %}
<a href="{{ site_url|default:'https://mkdrealtor.com' }}" style="color:#00626c;text-decoration:none;">{{ host_label|default:"mkdrealtor.com" }}</a> <a href="{{ site_url|default:'https://mkdrealtor.com' }}" style="color:#00626c;text-decoration:none;">{{ host_label|default:"mkdrealtor.com" }}</a>
{% if brand_tagline %} {% if brand_tagline %}
&nbsp;·&nbsp; {{ brand_tagline }} &nbsp;·&nbsp; {{ brand_tagline }}
{% endif %} {% endif %}
{% endif %}
</p> </p>
{% endblock %} {% endblock %}
</td> </td>
+26 -11
View File
@@ -8,6 +8,7 @@ from django.views.decorators.http import require_GET, require_http_methods
from analytics.services import attribute_lead_from_request from analytics.services import attribute_lead_from_request
from contacts.models import Channel, ConsentRecord, Contact from contacts.models import Channel, ConsentRecord, Contact
from contacts.services import upsert_contact
from leads.models import Lead from leads.models import Lead
from messaging.services import ( from messaging.services import (
channel_preferences, channel_preferences,
@@ -91,12 +92,6 @@ def contact(request):
form = ContactForm(request.POST) form = ContactForm(request.POST)
if form.is_valid(): if form.is_valid():
data = form.cleaned_data data = form.cleaned_data
defaults = {
"first_name": data["first_name"],
"last_name": data.get("last_name") or "",
"phone": data.get("phone") or "",
"source": Contact.Source.CONTACT_FORM,
}
postal = Contact.make_postal_address( postal = Contact.make_postal_address(
line1=data.get("address_line1") or "", line1=data.get("address_line1") or "",
line2=data.get("address_line2") or "", line2=data.get("address_line2") or "",
@@ -104,11 +99,16 @@ def contact(request):
state=data.get("address_state") or "", state=data.get("address_state") or "",
zip_code=data.get("address_zip") or "", zip_code=data.get("address_zip") or "",
) )
if Contact.postal_address_has_content(postal): submitted_email = data["email"].lower()
defaults["postal_address"] = postal contact_obj, _created, match_reason = upsert_contact(
contact_obj, _ = Contact.objects.update_or_create( email=submitted_email,
email=data["email"].lower(), first_name=data["first_name"],
defaults=defaults, last_name=data.get("last_name") or "",
phone=data.get("phone") or "",
postal_address=postal
if Contact.postal_address_has_content(postal)
else None,
source=Contact.Source.CONTACT_FORM,
) )
ConsentRecord.objects.update_or_create( ConsentRecord.objects.update_or_create(
contact=contact_obj, contact=contact_obj,
@@ -121,11 +121,26 @@ def contact(request):
channel=Channel.SMS, channel=Channel.SMS,
defaults={"opted_in": True, "reason": "contact_form"}, defaults={"opted_in": True, "reason": "contact_form"},
) )
if Contact.postal_address_has_content(postal):
ConsentRecord.objects.get_or_create(
contact=contact_obj,
channel=Channel.POSTCARD,
defaults={"opted_in": True, "reason": "contact_form"},
)
interest = data.get("interest") or "" interest = data.get("interest") or ""
interest_label = dict(ContactForm.INTEREST_CHOICES).get(interest, interest) interest_label = dict(ContactForm.INTEREST_CHOICES).get(interest, interest)
body = data.get("message") or "" body = data.get("message") or ""
if interest_label: if interest_label:
body = f"Interest: {interest_label}\n\n{body}".strip() body = f"Interest: {interest_label}\n\n{body}".strip()
if (
match_reason in {"phone", "address"}
and (contact_obj.email or "").lower() != submitted_email
):
body = (
f"Submitted email: {submitted_email} "
f"(merged by {match_reason} with "
f"{contact_obj.email or 'existing contact'})\n\n{body}"
).strip()
lead = Lead.objects.create( lead = Lead.objects.create(
contact=contact_obj, contact=contact_obj,
message=body, message=body,