Skip to main content
Skip to main content

Developers Doc

Email System: Developer Guide to Transactional Email

11 mins read 6 Views 3+

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.

PipelineLocationRenderingResponsible for
Core transactional stackincludes/Email/Shared EmailRenderer + EmailBranding + EmailHeadersImport success, property workflow, workspace registration & account mail
Contact Agent stackincludes/ContactAgent/Self-contained InquiryEmailRenderer + InquiryEmailContextBuilderInquiry notifications, agent replies, sender auto-replies
Identity / verificationincludes/Workspace/Identity/EmailVerificationNotifier.phpRenders through the core stack, dispatches on its ownEmail-verification links and confirmations (3.3.0)

Note: The Contact Agent pipeline is deliberately self-contained — it uses its own InquiryEmailRenderer and does not route through EmailRenderer. 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.

ClassResponsibilityKey methods
EmailConstantsRegistry of email-type slugs. Maps TYPE_* constants to templates and human labels. SETTINGS_GROUP = 'email'.email_templates()template_labels()
EmailRendererResolves the template for a type, merges branding context, and produces the HTML body.render( string $email_type, array $context ): string
EmailBrandingSupplies 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 fileUsed for
templates/emails/property-import-success.phpDemo-import completion email
templates/emails/property-notice.phpListing-workflow transitions (submitted / approved / published / rejected)
templates/emails/workspace-notice.phpGeneric workspace notices, including email-verification mail
templates/emails/workspace-registration-approved.phpRegistration approved
templates/emails/workspace-registration-activated.phpAccount activated
templates/emails/workspace-registration-rejected.phpRegistration rejected
templates/emails/workspace-registration-suspended.phpAccount 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 a From: header only when the hvnly_email_from_address setting is configured and passes is_email(). Otherwise it returns an empty string so WordPress uses its default wordpress@yourdomain sender — it deliberately does not fall back to admin_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_type filter, so it cannot leak an HTML content type onto unrelated site mail.
  • attach_plain_text_alt() registers a one-shot phpmailer_init callback that sets AltBody, giving every HTML message a plain-text alternative.

Security: Because from_header() only emits a validated address, a misconfigured or empty hvnly_email_from_address can never inject an arbitrary sender. When customizing the From header via hvnly_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.

NotifierTriggerEmail type / templateRecipients
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.phpImporting user + admin_email (de-duplicated)
PropertyWorkflowNotifierupdated/added_post_meta on listing-status (priority 30) and hvnly_property_workflow_transitionedSubmitted / Approved / Published / Rejected → emails/property-notice.php; admin alert property_admin_alertListing 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-*.phpRegistering agent + admin pending alert
WorkspaceAccountNotifierretrieve_password_notification_email filter, after_password_resethvnly_send_workspace_account_reset_emailhvnly_workspace_user_provisioned_for_agenthvnly_agent_profile_savedBranded password reset, password changed, invitation, profile updatedAffected workspace user
EmailVerificationNotifier (Workspace/Identity/)hvnly_workspace_agent_provisioned (priority 20), hvnly_email_verification_verifiedworkspace_email_verifyworkspace_email_verified → emails/workspace-notice.phpAgent whose email needs verifying
InquiryNotifier / InquiryReplyNotifier (ContactAgent/)Contact-agent form submission and agent replyRendered by InquiryEmailRendererReply-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 through hvnly_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.

FilterWhat it changes
hvnly_email_render_contextThe context array before a template renders
hvnly_email_htmlThe final rendered HTML body
hvnly_email_from_headerThe computed From: header
hvnly_email_templatesThe type → template map
hvnly_email_template_labelsHuman-readable type labels
hvnly_email_merge_tagsThe merge-tag replacement map
hvnly_email_import_success_context / _subject / _headersImport-success context, subject, headers
hvnly_email_logo_url / _support_url / _docs_url / _branding_contextBranding URLs and the branding context
hvnly_email_property_workflow_enabled / _context / _subjectProperty-workflow gate, context and subject
hvnly_email_property_admin_alert_emailAdmin alert recipient for workflow events
hvnly_workspace_registration_email_context / _subjectRegistration email context and subject
hvnly_workspace_registration_admin_alert_emailRegistration admin-alert recipient
hvnly_workspace_account_email_subjectAccount (reset / changed / invite) subject
hvnly_send_workspace_account_reset_emailWhether to send the account reset email
hvnly_email_verification_subject / _content / _enforced / _ttlVerification subject, content, enforcement and token TTL
hvnly_contact_agent_email_subject / _headers / _sender_email_headersInquiry 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.

ActionFires when
hvnly_email_import_success_sentImport-success email sent
hvnly_email_property_workflow_sentProperty-workflow email sent
hvnly_email_property_admin_alert_sentWorkflow admin alert sent
hvnly_workspace_registration_email_sentRegistration email sent
hvnly_workspace_account_email_sentAccount email sent
hvnly_contact_agent_inquiry_notifiedInquiry notification sent
hvnly_email_verification_email_before_send / _email_sent / _sent / _resent / _verified / _expired / _failedEach stage of the verification lifecycle
hvnly_email_changedA 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 core hvnly_email_* hooks do not touch the Contact Agent renderer.
  • Filter context, not markup, where possible. Adjusting hvnly_email_render_context or 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_address to a real mailbox on your sending domain so from_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

  • EmailSettings caches the email settings group and clears that cache on hvnly_settings_updated — avoid re-reading hvnly_plugin_settings yourself 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_init callback rather than a persistent global filter, so it adds no overhead to unrelated mail.

Security

  • Never inject raw or user-supplied values into FromReply-To, or subject headers. Route display names through EmailHeaders::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_email as the sender. When hvnly_email_from_address is empty, WordPress’s default wordpress@domain is used — not the admin address. Set the option if you want a specific From.
  • Filtering the wrong pipeline. Applying hvnly_email_html and wondering why inquiry emails are unchanged — inquiries render through InquiryEmailRenderer, not EmailRenderer.
  • 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.

Need more help?

Can't find what you're looking for? Our team and community are here to help you ship faster.