A Craft CMS email integration with Volanea does not require a marketplace plugin, but it does require a deliberate server-side integration. The reliable route is a custom Craft module that listens for a Craft element event, places a job on Craft’s queue, and has that job call the Volanea REST API.

Craft is a PHP application built on Yii, so it can be extended in code without exposing email credentials to browsers or asking content editors to understand API requests. This guide uses a newly created Entry as the concrete trigger: when a visitor’s submission is saved as an Entry in a dedicated section, Craft queues a transactional email.

That is a useful pattern for applications such as waitlists, quote requests, partner applications, registrations, and other workflows where a submitted record needs a confirmation email or an internal notification. It is not a native Volanea Craft CMS app, and it should not be presented to editors as one. It is a maintainable application integration owned in your Craft project repository.

What Craft CMS provides, and what this integration adds

Craft CMS has a capable module and event system, but an Entry save does not automatically mean “send this event to any HTTP endpoint.” A module is the appropriate scripting layer when your site needs a server-side response to a content event. It can subscribe to element events, inspect the saved element, and add work to Craft’s queue.

This matters because Craft Entries are content elements, not generic webhook records. An Entry can have a section, entry type, site, author, status, title, custom fields, revisions, and propagation behavior. A useful integration therefore needs to be explicit about which Entry represents a real submission and which fields are safe to turn into email input.

The integration in this article has four pieces:

  1. A submission record: a newly created Entry in a section such as applications or contactRequests.
  2. A Craft module listener: subscribes to Craft’s EVENT_AFTER_SAVE event for Entries and filters for the intended section.
  3. A queue job: reloads the Entry and sends the API request away from the editor or visitor request.
  4. A Volanea API adapter: maps Craft field handles to the REST request body and reads secrets from environment variables.

The queue boundary is important. A visitor who submits a form should receive a successful site response after Craft has accepted and stored the submission; they should not have to wait for an email provider’s network response. Likewise, an editor saving an Entry should not see a failed save merely because an external email API is temporarily slow.

Choose an Entry section deliberately

Create or use a section whose Entries represent the event that should trigger email. For example, a contactRequests channel could use these field handles:

  • email — the recipient’s email address.
  • firstName — optional recipient name used in the message.
  • message — the request content, used in an internal notification rather than blindly echoed to the visitor.
  • emailSentAt — optional Date/Time field for an audit marker.
  • volaneaMessageId — optional Plain Text field for a provider message identifier, if your API response supplies one.

Field handles are code-facing identifiers. They are not necessarily the labels editors see in the control panel. Before deploying, confirm the exact handles in Settings → Fields and make the module match them. A field labeled “Email address” may have a handle such as emailAddress, not email.

Do not use “every Entry save” as the trigger. A broad listener could send mail when an editor updates a landing page, saves a draft, propagates content to another site, or changes a title after publication. Filter by section, require a newly created entry, and add further business rules where needed.

The concrete Craft trigger: a newly created Entry

The trigger in this implementation is craft\elements\Entry::EVENT_AFTER_SAVE. The module receives a ModelEvent after Craft saves an Entry. The event’s isNew property lets the listener distinguish a new Entry from a later edit.

The listener also checks propagating. Craft can save an element while propagating it to another site in a multisite installation. Without that guard, one submission may produce more than one queue job and therefore more than one email.

A clean trigger rule is:

Queue an email only when Craft has newly saved a non-propagating Entry in the contactRequests section, with a non-empty email custom field.

This is intentionally narrower than “when a form is submitted.” Craft core is a CMS and does not impose one universal form-submission data model. A site may use a custom controller, a form plugin, or a headless frontend to create the Entry. Once that application path creates the Entry, the event is the stable backend trigger.

Register a project module

Create a module under modules/volanea-mail/ and register it in your Craft project configuration. The exact namespace and bootstrap configuration can vary by project, but a Craft module is the right place for integration code because it is deployed with the application and can be tested and reviewed like the rest of the codebase.

The following module listener uses Craft’s Entry event and puts a job on the queue. It does not make an HTTP request inside the event callback.

<?php
// modules/volaneamail/Module.php
namespace modules\volaneamail;

use Craft;
use craft\base\Module as BaseModule;
use craft\elements\Entry;
use craft\events\ModelEvent;
use modules\volaneamail\jobs\SendVolaneaEmailJob;
use yii\base\Event;

final class Module extends BaseModule
{
    public function init(): void
    {
        parent::init();

        Event::on(
            Entry::class,
            Entry::EVENT_AFTER_SAVE,
            static function (ModelEvent $event): void {
                /** @var Entry $entry */
                $entry = $event->sender;

                // Only first saves of real source entries should trigger mail.
                if (!$event->isNew || $entry->propagating) {
                    return;
                }

                $section = $entry->getSection();
                if ($section === null || $section->handle !== 'contactRequests') {
                    return;
                }

                // Match this handle to the field in this Craft project.
                if (empty($entry->email)) {
                    Craft::warning(
                        "Contact request {$entry->id} has no email field; no message queued.",
                        __METHOD__
                    );
                    return;
                }

                Craft::$app->getQueue()->push(new SendVolaneaEmailJob([
                    'entryId' => (int) $entry->id,
                    'siteId' => (int) $entry->siteId,
                ]));
            }
        );
    }
}

The callback stores only IDs in the job. That is preferable to serializing a full Entry object, which may contain relations, custom-field values, and state that will be stale by the time a worker runs. The job reloads the canonical record from the database.

Define the email contract before writing the API call

An Entry is not automatically an email payload. Define a small contract between the Craft content model and the transactional message. For a visitor confirmation, the message should normally use only trusted, expected fields: an address, a display name, and a server-generated reference number.

For this example, the field mapping is:

Craft Entry valueVolanea message valuePurpose
$entry->emailto[0].emailRecipient address
$entry->firstNameto[0].nameOptional recipient display name
fixed env valuefromAuthenticated sender address
fixed env valuereply_toInbox for replies
$entry->idmessage metadata/referenceInternal traceability
server-rendered HTMLhtmlConfirmation message body
plain-text equivalenttextAccessible and fallback body

Use a sender address from a domain you have configured for sending. Do not let a visitor-provided address become the from value. Apart from spoofing risks, that commonly damages alignment and deliverability. If you want staff to reply to the visitor, use a verified organizational From address and set a reply-to value only after validating it for your use case.

The message field in a public form is untrusted input. It is usually safer to send that content only in an internal staff notification, encode it when rendering HTML, and avoid placing it in an automated confirmation unless the product requirement clearly calls for it.

Build HTML on the server, not in a field value

The job below uses a server-owned template fragment. In a production Craft project, render a Twig template with Craft::$app->getView()->renderTemplate() or use a focused service that returns both HTML and plain text. Keeping the layout in a template makes branding, accessibility, and localization easier to maintain.

Never concatenate unescaped user data into HTML. In PHP, use htmlspecialchars() with ENT_QUOTES | ENT_SUBSTITUTE and UTF-8. In Twig, keep auto-escaping enabled. That prevents a first name or message entered in a form from becoming markup in the email.

Send the Volanea REST request from a Craft queue job

The code below is an adapter pattern: the endpoint and credentials are injected as environment variables, while the payload construction remains in one auditable class. Set VOLANEA_EMAIL_API_URL to the email-send endpoint shown in your Volanea account’s API reference, rather than hard-coding an endpoint copied into application code. The current email API reference and setup guides are the source of truth for the endpoint, supported fields, and response schema for your account.

This request uses the common transactional message shape of a sender, recipient list, subject, HTML, plain text, and metadata. Confirm each property against the Volanea API version you use before deploying; API contracts can change, and this adapter is precisely where a version-specific change should be isolated.

<?php
// modules/volaneamail/jobs/SendVolaneaEmailJob.php
namespace modules\volaneamail\jobs;

use Craft;
use craft\elements\Entry;
use craft\helpers\App;
use craft\queue\BaseJob;

final class SendVolaneaEmailJob extends BaseJob
{
    public int $entryId;
    public int $siteId;

    public function getDescription(): string
    {
        return "Sending Volanea confirmation for Entry #{$this->entryId}";
    }

    public function execute($queue): void
    {
        $entry = Entry::find()
            ->id($this->entryId)
            ->siteId($this->siteId)
            ->status(null)
            ->one();

        if (!$entry instanceof Entry) {
            Craft::warning("Entry {$this->entryId} no longer exists.", __METHOD__);
            return;
        }

        $recipientEmail = trim((string) $entry->email);
        if (!filter_var($recipientEmail, FILTER_VALIDATE_EMAIL)) {
            Craft::warning("Entry {$entry->id} has an invalid recipient email.", __METHOD__);
            return;
        }

        $firstName = trim((string) ($entry->firstName ?? ''));
        $safeFirstName = htmlspecialchars(
            $firstName !== '' ? $firstName : 'there',
            ENT_QUOTES | ENT_SUBSTITUTE,
            'UTF-8'
        );

        $subject = 'We received your request';
        $html = "<p>Hi {$safeFirstName},</p>"
            . "<p>Thanks for contacting us. Your reference is #{$entry->id}.</p>";
        $text = "Hi " . ($firstName !== '' ? $firstName : 'there')
            . ",\n\nThanks for contacting us. Your reference is #{$entry->id}.";

        // Map Craft fields to the Volanea transactional-email request body.
        $payload = [
            'from' => App::env('VOLANEA_FROM_EMAIL'),
            'to' => [[
                'email' => $recipientEmail,
                'name' => $firstName !== '' ? $firstName : null,
            ]],
            'reply_to' => App::env('VOLANEA_REPLY_TO_EMAIL'),
            'subject' => $subject,
            'html' => $html,
            'text' => $text,
            'metadata' => [
                'craft_entry_id' => (string) $entry->id,
                'craft_site_id' => (string) $entry->siteId,
                'message_type' => 'contact-request-confirmation',
            ],
        ];

        // Remove optional null fields before JSON encoding.
        $payload['to'][0] = array_filter(
            $payload['to'][0],
            static fn ($value) => $value !== null
        );
        $payload = array_filter(
            $payload,
            static fn ($value) => $value !== null && $value !== ''
        );

        $this->setProgress($queue, 0.5, 'Calling Volanea');

        $client = Craft::createGuzzleClient([
            'timeout' => 15,
            'connect_timeout' => 5,
        ]);

        $response = $client->post(App::env('VOLANEA_EMAIL_API_URL'), [
            'headers' => [
                'Authorization' => 'Bearer ' . App::env('VOLANEA_API_KEY'),
                'Accept' => 'application/json',
            ],
            'json' => $payload,
        ]);

        // Keep a provider identifier only after confirming its exact response key in the API docs.
        $responseBody = json_decode((string) $response->getBody(), true);
        Craft::info([
            'entryId' => $entry->id,
            'volaneaStatus' => $response->getStatusCode(),
            'volaneaResponse' => $responseBody,
        ], __METHOD__);

        $this->setProgress($queue, 1, 'Email accepted by Volanea');
    }
}

The code deliberately does not guess at a response property such as id or write it to a Craft custom field. Read the response defined by the API version in use, then add that small persistence step if it is valuable for support or reconciliation. Logging the complete response should also be reviewed carefully: do not log recipient addresses or message content in environments where that conflicts with your privacy policy.

Keep the Volanea API key out of client-visible configuration

The Volanea API key belongs in a server environment variable or your deployment platform’s secret store. In a Craft project, place variable names in environment-specific configuration and supply their values through production secrets management. The PHP process that runs Craft web requests and queue workers must both receive the same required variables.

For example, your local .env file may contain values like these, while production stores them in its platform’s encrypted environment configuration:

VOLANEA_API_KEY="replace-with-a-server-side-secret"
VOLANEA_EMAIL_API_URL="https://your-volanea-api-email-endpoint"
VOLANEA_FROM_EMAIL="Support <support@example.com>"
VOLANEA_REPLY_TO_EMAIL="support@example.com"

Do not commit .env files containing real credentials. Do not put the key in a Twig template, a JavaScript bundle, a data-* attribute, a public JSON configuration endpoint, or a form hidden field. A browser-visible API key can be copied by anyone who loads the page and used to send mail under your account.

This is also why a direct browser-to-email-provider request is the wrong architecture for a Craft form. The server must validate the submission, apply abuse controls, decide whether a message is warranted, and use the secret only after those checks succeed.

Separate environment concerns

Use separate Volanea credentials or sending domains for development, staging, and production where your operating model permits it. A staging content editor should not accidentally email a real customer list from a test environment. At minimum, add a recipient allowlist or route non-production messages to a controlled test inbox.

Also ensure that a long-running queue worker is restarted after you rotate a secret. A worker process can retain old environment values even after the deployment environment has changed. Secret rotation is complete only after web and queue processes are using the replacement credential.

Configure Craft’s queue for dependable delivery work

Craft’s queue turns the email call into background work, but it still needs a worker. On low-traffic sites, a queue that relies only on opportunistic web requests can leave jobs waiting. In production, run Craft’s queue worker under an appropriate process manager or scheduled worker arrangement recommended for your hosting environment.

The queue should be monitored as operational infrastructure. A successful form response only proves that Craft stored the Entry and queued the job. It does not prove that Volanea accepted the message, and acceptance does not prove inbox placement. Those are different states that should be visible in logs and provider reporting.

A sensible monitoring checklist includes:

  • Queue jobs waiting longer than your expected delivery window.
  • Failed jobs and their exception messages.
  • HTTP status classes returned by the Volanea endpoint.
  • Entry IDs associated with failures, without unnecessarily exposing message content.
  • Sudden increases in invalid recipient addresses.
  • Sender-domain authentication and deliverability signals in your email platform.

For a public submission flow, add rate limiting and bot protection before the Entry is created. Otherwise, an attacker can create a large number of valid-looking entries and cause large amounts of confirmation email. The email integration should be downstream of application-level abuse prevention, not the component expected to solve it.

When this breaks: failures in the Craft-to-Volanea hop

The difficult part of transactional email is not making a single happy-path request. It is deciding what happens when Craft, the queue worker, the network, and the provider disagree about whether a message was sent.

Queue retries can create duplicate sends

A network timeout is ambiguous. Volanea may have accepted the request, while Craft fails to receive the response. If Craft retries the failed job, the recipient may receive the confirmation twice.

Treat this as an idempotency problem, not merely a retry problem. First, check whether your Volanea API version supports an idempotency key and use its documented header or request field exactly as specified. Derive a stable value from the business event, such as craft-contact-confirmation-ENTRY_ID, rather than a random value generated on every attempt.

If the API does not provide documented idempotency support, build local deduplication. A robust version stores an immutable “email intent” record keyed by Entry ID and message type before queuing work, then marks it accepted only after a known provider response. Use a database uniqueness constraint rather than relying only on a custom field check, because two workers can otherwise race. Be careful with a simple emailSentAt flag: setting it before the request can suppress a necessary retry; setting it after the request can still allow a duplicate after an ambiguous timeout.

Webhook-style timeouts and request timeouts are different

This module is not waiting for an inbound webhook. It is making an outbound HTTP request from a queue worker. A connect timeout often indicates DNS, routing, firewall, or TLS trouble. A read timeout means a connection was made but the response did not arrive within the configured period.

Do not solve timeouts by raising the timeout to several minutes inside an editor request. The queue already protects the interactive request path. Use finite connect and total timeouts, log enough context to diagnose the failure, and retry only errors that are plausibly transient. Authentication failures, malformed JSON, and rejected sender identities generally need configuration fixes, not automatic repeated attempts.

Payload fields may be missing or shaped differently

An Entry event does not guarantee that every custom field is populated. A field can be absent from an Entry Type layout, blank on older Entries, unavailable for a particular site, or represented differently when it is a relation or complex field rather than a plain-text field. A multi-site Entry can also have localized values.

There is another source of missing fields when a project uses a third-party form service or automation middleware upstream: its plan, connector, or mapping may not expose every submitted value. Do not assume a field that appears in one test payload will always appear in production. Validate required inputs at the form/controller layer and validate again in the queue job before sending.

For recipients, a missing or invalid address should result in no provider call. For optional fields such as first name, use a safe fallback. For complex fields, convert them to a deliberate scalar value in one mapping function; never rely on PHP casting an object or array into an email payload.

Entry edits, drafts, and multisite propagation can surprise you

The isNew and propagating checks in the example prevent common duplicate paths, but your editorial workflow may have additional states. If content authors create drafts before publication, decide whether creation, publication, or a specific status change is the true business event. A confirmation email for a contact request usually belongs to record creation; a customer lifecycle email may instead belong to a purpose-built service method, not an editor-driven content event.

Test the exact workflow your team uses: create a new Entry, save a draft, publish it, edit it, resave it, and save it in every site. Count queue jobs and messages for each action. That small test matrix catches many problems before a campaign or onboarding flow reaches real people.

Test the integration without emailing customers

Start with a controlled inbox and a dedicated test section. Create an Entry manually in the Craft control panel with a known test address, then confirm that exactly one queue job appears and that the worker processes it. Inspect the generated request in development logs only after redacting or protecting personal data appropriately.

Next, test negative cases. Create an Entry with an empty email field, an invalid address, missing first name, and an unexpected complex field value. The expected behavior should be explicit: invalid recipients are rejected locally, optional names fall back to “there,” and no secret or raw request body appears in browser output.

Then test operational cases:

  1. Temporarily use an invalid API key and verify that the queue exposes a meaningful failure.
  2. Temporarily use a non-routable endpoint in a non-production environment and observe timeout behavior.
  3. Retry a job after a simulated ambiguous failure and verify your idempotency or deduplication design.
  4. Save the same Entry again and confirm that isNew prevents another confirmation.
  5. Create the Entry on another site in a multisite installation and confirm propagation does not duplicate it.

Testing the content is equally important. Review the HTML and plain-text parts in multiple mail clients, use an address with a plus tag to trace delivery, and make sure reply handling reaches a staffed inbox. A message that is syntactically accepted but unusable by a recipient is still an integration failure.

Extend the pattern for internal notifications and lifecycle messages

Once the basic confirmation is reliable, keep message types separate. A visitor confirmation and an internal sales notification have different recipients, content, sensitivity, and retry consequences. Give each type an explicit name in metadata and a distinct template or payload builder.

For example, an internal notification could send to sales@example.com after a new contactRequests Entry is created, with the submitter’s message rendered safely in a staff-only template. The customer confirmation should not include internal routing information, and staff mail should not use the customer’s address as its sender.

For more complex lifecycle workflows—such as “send only after an order is paid” or “send when a customer reaches a defined product stage”—consider triggering from the application service that commits that business event. Content-element events are excellent for content-driven records, but they are not a substitute for a durable domain model when money, permissions, or fulfillment states are involved.

Keep the Volanea-specific code in a small service or job class. That makes it easier to update API versions, add tags, use templates, or change sending behavior without touching the code that creates Craft Entries.

Deliverability and privacy considerations

A correctly authenticated API request is only the beginning of email delivery. Configure the sending domain with the DNS records required by your Volanea account, use a recognizable sender identity, and send messages that recipients expect as a direct result of their action.

Transactional messages should be concise and specific: explain what happened, identify the organization, provide a useful next step, and avoid promotional content in a confirmation that a person did not ask to receive. If you also want marketing consent, capture and store that consent separately from the operational confirmation trigger.

Limit personal data in metadata and logs. An Entry ID is generally a better correlation key than a full email address or free-form submission message. This reduces exposure in queue dashboards, error trackers, and support logs while preserving the ability to investigate a particular send.

Conclusion: make the integration explicit and queue-backed

A dependable Craft CMS email integration with Volanea is a small backend system: a specific Entry creation event, a carefully scoped module listener, a background queue job, a field mapping contract, and server-side credentials. That is more work than a hypothetical one-click plugin, but it gives your team control over triggers, templates, retries, and privacy.

Begin with one narrow event—such as a newly created contactRequests Entry—then test duplicates, missing fields, and failures before expanding the flow. Keep API configuration in environment variables, consult the Volanea API reference for the exact endpoint and versioned request schema, and treat retry behavior as a product decision rather than an afterthought.

FAQ

Does Volanea have a native Craft CMS plugin?

No native Craft CMS Volanea app, marketplace listing, or one-click plugin flow is assumed here. This integration uses Craft’s module and event extension system plus a server-side REST API request.

What exactly triggers the email in this example?

The email is queued when Craft saves a newly created, non-propagating Entry in the contactRequests section. The listener uses Entry::EVENT_AFTER_SAVE, checks isNew, and filters by the section handle.

Why use Craft’s queue instead of calling the API during form submission?

A queue prevents an external email API delay or temporary outage from blocking the visitor’s or editor’s request. It also gives your team a place to inspect and retry failed background work.

Where should the Volanea API key be stored?

Store it in a server-side environment variable or deployment secret manager, available to both Craft web requests and queue workers. Never expose it in JavaScript, Twig output, public configuration, or a browser form.

How do I prevent duplicate emails after a retry?

Use Volanea’s documented idempotency mechanism if the API version provides one, with a stable key derived from the Entry and message type. Otherwise, implement a database-backed email-intent or deduplication record; do not rely only on an in-memory check or a post-send timestamp field.