The Havenlytics email system is the transactional email layer that powers property-import confirmations, listing-workflow notifications, agent inquiry messages, workspace account mail, and email verification. Every message is composed as branded HTML, rendered through theme-overridable templates, and delivered with WordPress’s own wp_mail(). This developer guide explains the internal architecture, the core rendering classes, the DMARC-safe From handling, and every filter and action you can use to extend or re-brand Havenlytics email without editing plugin files.
Note: Every hook on this page is prefixed
hvnly_. If a filter or action is not listed here, it does not exist in Havenlytics 3.3.1 — do not rely on it.
Architecture: three parallel pipelines
Havenlytics does not have a single email class. It has three independent pipelines that all converge on wp_mail(). Knowing which pipeline owns a given message tells you which filters apply and which template renders it.
| Pipeline | Location | Rendering | Responsible for |
|---|---|---|---|
| Core transactional stack | includes/Email/ | Shared EmailRenderer + EmailBranding + EmailHeaders | Import success, property workflow, workspace registration & account mail |
| Contact Agent stack | includes/ContactAgent/ | Self-contained InquiryEmailRenderer + InquiryEmailContextBuilder | Inquiry notifications, agent replies, sender auto-replies |
| Identity / verification | includes/Workspace/Identity/EmailVerificationNotifier.php | Renders through the core stack, dispatches on its own | Email-verification links and confirmations (3.3.0) |
Note: The Contact Agent pipeline is deliberately self-contained — it uses its own
InquiryEmailRendererand does not route throughEmailRenderer. If you filter core rendering hooks, they will not affect inquiry email, and vice versa.
Core email classes (includes/Email/)
The core stack is a small set of single-responsibility classes. Together they turn an email-type slug and a context array into a finished, branded HTML message with safe headers.
| Class | Responsibility | Key methods |
|---|---|---|
EmailConstants | Registry of email-type slugs. Maps TYPE_* constants to templates and human labels. SETTINGS_GROUP = 'email'. | email_templates(), template_labels() |
EmailRenderer | Resolves the template for a type, merges branding context, and produces the HTML body. | render( string $email_type, array $context ): string |
EmailBranding | Supplies logo, support URL, docs URL, plugin version and site name to every template. | context(), logo_url() (from the custom_logo theme mod) |
EmailHeaders (final) | Builds safe mail headers and the plain-text alternative. | base(), from_header(), reply_to(), sanitize_phrase(), attach_plain_text_alt() |
EmailContextBuilder (final) | Builds the import-success context and runs the {{merge_tag}} engine. | replace_merge_tags(), merge_tag_map() |
EmailSettings (final) | Reads the email group from hvnly_plugin_settings; its cache is cleared on hvnly_settings_updated. | Settings accessors for the email group |
How rendering flows
EmailRenderer::render() takes an email-type slug (for example property_import_success), looks up the matching template file via EmailConstants::email_templates(), merges the shared EmailBranding::context() into the caller’s context array, and delegates the actual PHP-template rendering to the plugin’s template loader, hvnly_get_template_html().
<?php
// Conceptual flow inside EmailRenderer::render()
$template = EmailConstants::email_templates()[ $email_type ]; // e.g. 'emails/property-import-success.php'
$context = array_merge( EmailBranding::context(), $context );
$html = hvnly_get_template_html( $template, $context ); // extract($args), theme-overridable
return $html;
Templates and theme overrides
All core email bodies live in templates/emails/*.php and are rendered by hvnly_get_template_html(), which extract()s the context array into template scope and looks in the active theme first. That means every email template is theme-overridable: copy the file into your theme and it wins over the plugin copy.
| Template file | Used for |
|---|---|
templates/emails/property-import-success.php | Demo-import completion email |
templates/emails/property-notice.php | Listing-workflow transitions (submitted / approved / published / rejected) |
templates/emails/workspace-notice.php | Generic workspace notices, including email-verification mail |
templates/emails/workspace-registration-approved.php | Registration approved |
templates/emails/workspace-registration-activated.php | Account activated |
templates/emails/workspace-registration-rejected.php | Registration rejected |
templates/emails/workspace-registration-suspended.php | Account suspended |
Tip: To re-skin an email without touching the plugin, copy the template into
your-theme/havenlytics/emails/. The template loader resolves the theme copy first — the plugin file is the fallback. See Template Overrides for the full resolution order.
Overriding an email template in a theme
// 1. Locate the plugin template:
// wp-content/plugins/havenlytics/templates/emails/property-notice.php
//
// 2. Copy it into your active (or child) theme, preserving the path:
// wp-content/themes/your-theme/havenlytics/emails/property-notice.php
//
// 3. Edit the copy. Havenlytics renders your version instead.
// The same context variables (branding, listing data, merge tags)
// are extracted into scope, so keep the variable names intact.
DMARC-hardened From handling
Spoofing the site’s own domain in the From header is the fastest way to fail DMARC and land in spam. Havenlytics handles this defensively in EmailHeaders.
from_header()returns aFrom:header only when thehvnly_email_from_addresssetting is configured and passesis_email(). Otherwise it returns an empty string so WordPress uses its defaultwordpress@yourdomainsender — it deliberately does not fall back toadmin_email.- The From-name falls back to
get_bloginfo('name')when no custom name is set. sanitize_phrase()strips CRLF and tab characters plus, " < > ; :from any display phrase, closing header-injection vectors.- Content type is set per message through the header array — Havenlytics never sets the global
wp_mail_content_typefilter, so it cannot leak an HTML content type onto unrelated site mail. attach_plain_text_alt()registers a one-shotphpmailer_initcallback that setsAltBody, giving every HTML message a plain-text alternative.
Security: Because
from_header()only emits a validated address, a misconfigured or emptyhvnly_email_from_addresscan never inject an arbitrary sender. When customizing the From header viahvnly_email_from_header, keep the same discipline — never echo raw user input into a header. See Security.
Notifiers: what sends what, and when
Notifiers are the classes that decide a message should go out, build its context, and call the renderer. Each is gated so it only fires in a genuine system-notification context.
| Notifier | Trigger | Email type / template | Recipients |
|---|---|---|---|
PropertyImportSuccessNotifier (Email/Notifiers/) | After demo import, via hvnly_send_property_import_success_email(); gated by import_success_enabled() | property_import_success → emails/property-import-success.php | Importing user + admin_email (de-duplicated) |
PropertyWorkflowNotifier | updated/added_post_meta on listing-status (priority 30) and hvnly_property_workflow_transitioned | Submitted / Approved / Published / Rejected → emails/property-notice.php; admin alert property_admin_alert | Listing owner + admin; once-per-transition dedup via _hvnly_last_emailed_listing_status |
RegistrationEmailNotifier (Workspace/Auth/) | hvnly_workspace_registration_status_changed (priority 20) | Pending / Approved / Rejected / Suspended / Activated / Archived / Register → emails/workspace-*.php | Registering agent + admin pending alert |
WorkspaceAccountNotifier | retrieve_password_notification_email filter, after_password_reset, hvnly_send_workspace_account_reset_email, hvnly_workspace_user_provisioned_for_agent, hvnly_agent_profile_saved | Branded password reset, password changed, invitation, profile updated | Affected workspace user |
EmailVerificationNotifier (Workspace/Identity/) | hvnly_workspace_agent_provisioned (priority 20), hvnly_email_verification_verified | workspace_email_verify, workspace_email_verified → emails/workspace-notice.php | Agent whose email needs verifying |
InquiryNotifier / InquiryReplyNotifier (ContactAgent/) | Contact-agent form submission and agent reply | Rendered by InquiryEmailRenderer; Reply-To via EmailHeaders::reply_to() | Agent, admin, and the sender (auto-reply) |
Note: All notifiers guard their sends with
hvnly_is_system_notification_context()and log throughhvnly_debug_log(). If a notifier is firing at the wrong time, that guard and that log are the first places to look.
Email verification (3.3.0)
The identity pipeline builds its verification link in EmailVerificationNotifier::build_verify_context() using WorkspaceSettings::route_url('verify-email', ['token' => $token, 'login' => $user_login]). The token itself is produced by EmailVerificationFactor::start_verification(), and on_verified() sends the confirmation email once the link is used. See Authentication for how tokens are generated and validated.
Extension points
Filters
Use these to alter rendering, branding, subjects, headers and merge tags without editing plugin code.
| Filter | What it changes |
|---|---|
hvnly_email_render_context | The context array before a template renders |
hvnly_email_html | The final rendered HTML body |
hvnly_email_from_header | The computed From: header |
hvnly_email_templates | The type → template map |
hvnly_email_template_labels | Human-readable type labels |
hvnly_email_merge_tags | The merge-tag replacement map |
hvnly_email_import_success_context / _subject / _headers | Import-success context, subject, headers |
hvnly_email_logo_url / _support_url / _docs_url / _branding_context | Branding URLs and the branding context |
hvnly_email_property_workflow_enabled / _context / _subject | Property-workflow gate, context and subject |
hvnly_email_property_admin_alert_email | Admin alert recipient for workflow events |
hvnly_workspace_registration_email_context / _subject | Registration email context and subject |
hvnly_workspace_registration_admin_alert_email | Registration admin-alert recipient |
hvnly_workspace_account_email_subject | Account (reset / changed / invite) subject |
hvnly_send_workspace_account_reset_email | Whether to send the account reset email |
hvnly_email_verification_subject / _content / _enforced / _ttl | Verification subject, content, enforcement and token TTL |
hvnly_contact_agent_email_subject / _headers / _sender_email_headers | Inquiry subject and headers (Contact Agent pipeline) |
Actions
These fire after a message is sent (or at each step of verification), so you can log, sync a CRM, or trigger downstream automation.
| Action | Fires when |
|---|---|
hvnly_email_import_success_sent | Import-success email sent |
hvnly_email_property_workflow_sent | Property-workflow email sent |
hvnly_email_property_admin_alert_sent | Workflow admin alert sent |
hvnly_workspace_registration_email_sent | Registration email sent |
hvnly_workspace_account_email_sent | Account email sent |
hvnly_contact_agent_inquiry_notified | Inquiry notification sent |
hvnly_email_verification_email_before_send / _email_sent / _sent / _resent / _verified / _expired / _failed | Each stage of the verification lifecycle |
hvnly_email_changed | A workspace user’s email address changes |
Code examples
Customize an email subject via filter
<?php
/**
* Rebrand the property-import success email subject line.
* hvnly_email_import_success_subject passes the default subject string.
*/
add_filter( 'hvnly_email_import_success_subject', function ( $subject ) {
return 'Your demo listings are ready — ' . get_bloginfo( 'name' );
} );
Add custom merge tags
<?php
/**
* Register extra {{merge_tags}} available to core email templates.
* hvnly_email_merge_tags passes ( array $tags, array $context ).
* Return the map with your additions; keys are the tag names
* (used as {{agency_phone}} inside the template).
*/
add_filter( 'hvnly_email_merge_tags', function ( $tags, $context ) {
$tags['agency_phone'] = get_option( 'my_agency_phone', '' );
$tags['support_hours'] = 'Mon–Fri, 9am–6pm';
return $tags;
}, 10, 2 );
Change the email logo
<?php
/**
* Point every branded email at a dedicated email logo,
* independent of the site's custom_logo theme mod.
*/
add_filter( 'hvnly_email_logo_url', function ( $url ) {
return 'https://cdn.example.com/brand/email-logo.png';
} );
Best practices
- Pick the right pipeline. To affect inquiry email, use the
hvnly_contact_agent_*filters — the corehvnly_email_*hooks do not touch the Contact Agent renderer. - Filter context, not markup, where possible. Adjusting
hvnly_email_render_contextor adding merge tags is more upgrade-safe than overriding a whole template. - When you must change layout, override the template in your theme so plugin updates never clobber your work.
- Configure
hvnly_email_from_addressto a real mailbox on your sending domain sofrom_header()emits a DMARC-aligned sender. - Return early / return the value unchanged in your filters when your condition is not met — never unconditionally rewrite a shared subject or header.
Performance
EmailSettingscaches the email settings group and clears that cache onhvnly_settings_updated— avoid re-readinghvnly_plugin_settingsyourself inside a hot path.- Import-success and workflow mail are de-duplicated (recipient dedup and once-per-transition meta) so bulk operations do not fan out into duplicate sends. Preserve that behavior if you extend the notifiers.
- The plain-text alternative is attached with a one-shot
phpmailer_initcallback rather than a persistent global filter, so it adds no overhead to unrelated mail.
Security
- Never inject raw or user-supplied values into
From,Reply-To, or subject headers. Route display names throughEmailHeaders::sanitize_phrase()and strip CRLF from subjects. - Email values are decoded once with
html_entity_decode()and then re-escaped at output — mirror that pattern in custom templates rather than echoing raw context. - Keep verification links single-use and expiring; do not persist or log raw tokens. See Security and Authentication.
Common mistakes
- Expecting
admin_emailas the sender. Whenhvnly_email_from_addressis empty, WordPress’s defaultwordpress@domainis used — not the admin address. Set the option if you want a specific From. - Filtering the wrong pipeline. Applying
hvnly_email_htmland wondering why inquiry emails are unchanged — inquiries render throughInquiryEmailRenderer, notEmailRenderer. - Setting the global
wp_mail_content_type. Havenlytics intentionally sets content type per message; adding a global filter can break other plugins’ plain-text mail. - Hard-coding template paths. Always let
hvnly_get_template_html()resolve the path so theme overrides continue to work.