Skip to content

Webshop - Notifications (Email System)

Overview

The notification system handles all email communications with a 3-layer template architecture. Tenants have their own branding but share templates managed by Atraxion.

Template Architecture (3 Layers)

┌─────────────────────────────────────────────────────────────┐
│ LAYOUT (Complete HTML structure) - Managed by IT            │
│ <!DOCTYPE html><html>                                        │
│   <head>...</head>                                           │
│   <body>                                                     │
│     [Header with tenant logo]                                │
│     ┌───────────────────────────────────────────────────┐   │
│     │ TEMPLATE (Email-specific structure) - Managed by IT│   │
│     │ <table>                                            │   │
│     │   <tr><td><h2>{{ subject }}</h2></td></tr>        │   │
│     │   <tr><td>                                         │   │
│     │     ┌─────────────────────────────────────────┐   │   │
│     │     │ CONTENT (Message body) - ATX Staff      │   │   │
│     │     │ <p>Dear {firstname},</p>                │   │   │
│     │     │ <p>Your order has been shipped...</p>   │   │   │
│     │     └─────────────────────────────────────────┘   │   │
│     │   </td></tr>                                       │   │
│     │ </table>                                           │   │
│     └───────────────────────────────────────────────────┘   │
│     [Footer with company info]                               │
│   </body>                                                    │
│ </html>                                                      │
└─────────────────────────────────────────────────────────────┘

Layer Responsibilities

Layer Managed by Contains
Layout IT Full HTML structure, CSS, header/footer
Template IT Email-specific structure, variable definitions
Content ATX Staff Translatable message text per language

Entities

#[ORM\Entity]
class EmailLayout
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private int $id;

    #[ORM\Column(length: 100)]
    private string $name;

    #[ORM\Column(type: 'text')]
    private string $html; // Contains {{ content }} placeholder

    #[ORM\Column(type: 'text', nullable: true)]
    private ?string $css;

    #[ORM\OneToMany(mappedBy: 'layout', targetEntity: EmailTemplate::class)]
    private Collection $templates;
}

#[ORM\Entity]
class EmailTemplate
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private int $id;

    #[ORM\Column(length: 100, unique: true)]
    private string $handle; // e.g., 'order-confirmation-nl'

    #[ORM\Column(length: 100)]
    private string $name;

    #[ORM\ManyToOne(targetEntity: EmailLayout::class)]
    private EmailLayout $layout;

    #[ORM\Column(length: 2)]
    private string $language; // 'nl', 'fr', 'en'

    #[ORM\Column(type: 'text')]
    private string $html; // Contains {{ subject }} and {{ content }} placeholders

    #[ORM\Column(type: 'json')]
    private array $variables; // Variable definitions with descriptions

    #[ORM\OneToOne(mappedBy: 'template', targetEntity: EmailContent::class)]
    private ?EmailContent $content;
}

#[ORM\Entity]
class EmailContent
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private int $id;

    #[ORM\Column(length: 100)]
    private string $name;

    #[ORM\OneToOne(targetEntity: EmailTemplate::class)]
    private EmailTemplate $template;

    #[ORM\Column(length: 255)]
    private string $subject;

    #[ORM\Column(type: 'text')]
    private string $html; // Message body with {variable} placeholders
}

Email Service

class EmailTemplateService
{
    public function createEmail(
        string $templateHandle,
        array $context,
        Tenant $tenant
    ): Email {
        // Find template by handle
        $template = $this->templateRepository->findByHandle($templateHandle);

        if (!$template) {
            throw new TemplateNotFoundException($templateHandle);
        }

        // Get content
        $content = $template->getContent();

        if (!$content) {
            throw new ContentNotFoundException($templateHandle);
        }

        // Render content with variables
        $renderedContent = $this->renderContent($content->getHtml(), $context);
        $renderedSubject = $this->renderContent($content->getSubject(), $context);

        // Inject content into template
        $templateHtml = str_replace(
            ['{{ subject }}', '{{ content }}'],
            [$renderedSubject, $renderedContent],
            $template->getHtml()
        );

        // Inject template into layout
        $layout = $template->getLayout();
        $finalHtml = str_replace(
            ['{{ content }}', '{{ css }}'],
            [$templateHtml, $layout->getCss() ?? ''],
            $layout->getHtml()
        );

        // Add tenant branding
        $finalHtml = $this->addTenantBranding($finalHtml, $tenant);

        // Create email
        $email = new Email();
        $email->subject($renderedSubject);
        $email->html($finalHtml);
        $email->from(new Address(
            sprintf('info@%s.tyrecloud.be', $tenant->getSubdomain()),
            $tenant->getName()
        ));

        return $email;
    }

    private function renderContent(string $content, array $context): string
    {
        // Replace {variable} placeholders with actual values
        foreach ($context as $key => $value) {
            $content = str_replace('{' . $key . '}', $value, $content);
        }

        return $content;
    }

    private function addTenantBranding(string $html, Tenant $tenant): string
    {
        // Replace logo placeholder with tenant logo
        if ($tenant->getLogoPath()) {
            $logoUrl = $this->assetHelper->getUrl($tenant->getLogoPath());
            $html = str_replace('{{ tenant_logo }}', $logoUrl, $html);
        }

        return $html;
    }
}

Email Types

Transactional Emails

Email Template Handle Trigger
Order Confirmation order-confirmation-{lang} Order created
Payment Received payment-received-{lang} Payment confirmed
Order Shipped order-shipped-{lang} Tracking added
Order Delivered order-delivered-{lang} Delivery confirmed

Account Emails

Email Template Handle Trigger
Welcome welcome-{lang} Registration complete
Password Reset password-reset-{lang} Reset requested
Login Changed login-changed-{lang} Email/password changed
Account Approved account-approved-{lang} B2B approval
Account Rejected account-rejected-{lang} B2B rejection

Marketing Emails

Email Template Handle Trigger
Birthday birthday-{lang} Scheduled (birthday date)
Loyalty Update loyalty-update-{lang} Points earned/redeemed

Email Event Handlers

class OrderEmailSubscriber implements EventSubscriberInterface
{
    public static function getSubscribedEvents(): array
    {
        return [
            OrderCreatedEvent::class => 'onOrderCreated',
            OrderPaidEvent::class => 'onOrderPaid',
            OrderShippedEvent::class => 'onOrderShipped',
        ];
    }

    public function onOrderCreated(OrderCreatedEvent $event): void
    {
        $order = $event->order;
        $customer = $order->getCustomer();
        $tenant = $order->getTenant();
        $language = $customer->getPreferredLanguage();

        $email = $this->emailTemplateService->createEmail(
            "order-confirmation-{$language}",
            [
                'firstname' => $customer->getFirstName(),
                'lastname' => $customer->getLastName(),
                'order_number' => $order->getOrderNumber(),
                'order_date' => $order->getCreatedAt()->format('d/m/Y'),
                'order_total' => $this->moneyFormatter->format($order->getGrandTotal()),
            ],
            $tenant
        );

        $email->to($customer->getEmail());

        $this->mailer->send($email);
    }

    public function onOrderPaid(OrderPaidEvent $event): void
    {
        $order = $event->order;
        $customer = $order->getCustomer();
        $tenant = $order->getTenant();
        $language = $customer->getPreferredLanguage();

        $email = $this->emailTemplateService->createEmail(
            "payment-received-{$language}",
            [
                'firstname' => $customer->getFirstName(),
                'order_number' => $order->getOrderNumber(),
                'payment_method' => $order->getPaymentMethod(),
                'amount' => $this->moneyFormatter->format($order->getGrandTotal()),
            ],
            $tenant
        );

        $email->to($customer->getEmail());

        $this->mailer->send($email);
    }

    public function onOrderShipped(OrderShippedEvent $event): void
    {
        $order = $event->order;
        $customer = $order->getCustomer();
        $tenant = $order->getTenant();
        $language = $customer->getPreferredLanguage();

        $email = $this->emailTemplateService->createEmail(
            "order-shipped-{$language}",
            [
                'firstname' => $customer->getFirstName(),
                'order_number' => $order->getOrderNumber(),
                'tracking_number' => $event->trackingNumber,
                'tracking_url' => $event->trackingUrl,
                'carrier' => $order->getShippingMethod(),
            ],
            $tenant
        );

        $email->to($customer->getEmail());

        $this->mailer->send($email);
    }
}

Birthday Email Scheduler

class BirthdayEmailCommand extends Command
{
    protected static $defaultName = 'app:send-birthday-emails';

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $today = new \DateTimeImmutable();

        // Find customers with birthday today
        $customers = $this->customerRepository->findByBirthday(
            $today->format('m'),
            $today->format('d')
        );

        foreach ($customers as $customer) {
            $tenant = $customer->getTenant();
            $language = $customer->getPreferredLanguage();

            try {
                $email = $this->emailTemplateService->createEmail(
                    "birthday-{$language}",
                    [
                        'firstname' => $customer->getFirstName(),
                        'lastname' => $customer->getLastName(),
                    ],
                    $tenant
                );

                $email->to($customer->getEmail());

                $this->mailer->send($email);

                $output->writeln("Sent birthday email to {$customer->getEmail()}");
            } catch (\Exception $e) {
                $output->writeln("Failed to send to {$customer->getEmail()}: {$e->getMessage()}");
            }
        }

        return Command::SUCCESS;
    }
}

Multi-Language Support

Each template exists in 3 languages (NL, FR, EN):

class EmailTemplateResolver
{
    public function resolve(string $baseHandle, string $language): EmailTemplate
    {
        // Try requested language
        $template = $this->repository->findByHandle("{$baseHandle}-{$language}");

        if ($template) {
            return $template;
        }

        // Fallback to Dutch
        $template = $this->repository->findByHandle("{$baseHandle}-nl");

        if ($template) {
            return $template;
        }

        throw new TemplateNotFoundException($baseHandle);
    }
}

Admin Interface

Content Editing (ATX Staff)

class EmailContentController
{
    #[Route('/admin/email-templates', name: 'admin_email_templates')]
    public function index(): Response
    {
        $templates = $this->templateRepository->findAll();

        return $this->render('admin/email_templates/index.html.twig', [
            'templates' => $templates,
        ]);
    }

    #[Route('/admin/email-templates/{id}/edit', name: 'admin_email_template_edit')]
    public function edit(int $id, Request $request): Response
    {
        $template = $this->templateRepository->find($id);
        $content = $template->getContent();

        $form = $this->createForm(EmailContentType::class, $content);
        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            $this->entityManager->flush();

            return $this->redirectToRoute('admin_email_templates');
        }

        return $this->render('admin/email_templates/edit.html.twig', [
            'template' => $template,
            'content' => $content,
            'form' => $form,
            'variables' => $template->getVariables(), // Show available variables
        ]);
    }

    #[Route('/admin/email-templates/{id}/preview', name: 'admin_email_template_preview')]
    public function preview(int $id): Response
    {
        $template = $this->templateRepository->find($id);

        // Generate preview with sample data
        $sampleData = $this->generateSampleData($template->getVariables());

        $html = $this->emailTemplateService->renderPreview($template, $sampleData);

        return new Response($html);
    }
}

Gherkin Scenarios

Feature: Email Notifications
  As a system
  I want to send transactional emails
  So that customers are informed about their orders

  Scenario: Order confirmation email
    Given a customer places an order
    Then an order confirmation email should be sent
    And the email should contain the order number
    And the email should be in the customer's language
    And the email should have the tenant's logo
    And the from address should be "info@[tenant].tyrecloud.be"

  Scenario: Shipping notification with tracking
    Given an order has been shipped
    And tracking number "1234567890" has been added
    Then a shipping notification email should be sent
    And the email should contain the tracking link

  Scenario: Birthday email
    Given a customer has their birthday today
    When the birthday email job runs
    Then the customer should receive a birthday email

  Scenario: Multi-language support
    Given a customer with preferred language "fr"
    When they place an order
    Then the confirmation email should be in French

  Scenario: Language fallback
    Given a customer with preferred language "de"
    And no German template exists
    When they place an order
    Then the confirmation email should be in Dutch (fallback)

  Scenario: Edit email content
    Given I am logged in as ATX staff
    When I edit the order confirmation template
    And I change the greeting to "Hello {firstname}!"
    Then the new greeting should be used in future emails

Database Schema

-- Email layouts
CREATE TABLE email_layouts (
    id INT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(100) NOT NULL,
    html TEXT NOT NULL,
    css TEXT,
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL
);

-- Email templates
CREATE TABLE email_templates (
    id INT PRIMARY KEY AUTO_INCREMENT,
    handle VARCHAR(100) NOT NULL UNIQUE,
    name VARCHAR(100) NOT NULL,
    layout_id INT NOT NULL,
    language CHAR(2) NOT NULL,
    html TEXT NOT NULL,
    variables JSON NOT NULL,
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    FOREIGN KEY (layout_id) REFERENCES email_layouts(id),
    INDEX idx_handle (handle)
);

-- Email content (editable by ATX staff)
CREATE TABLE email_contents (
    id INT PRIMARY KEY AUTO_INCREMENT,
    template_id INT NOT NULL UNIQUE,
    name VARCHAR(100) NOT NULL,
    subject VARCHAR(255) NOT NULL,
    html TEXT NOT NULL,
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    FOREIGN KEY (template_id) REFERENCES email_templates(id)
);

-- Email log for debugging
CREATE TABLE email_log (
    id INT PRIMARY KEY AUTO_INCREMENT,
    tenant_id INT NOT NULL,
    template_handle VARCHAR(100) NOT NULL,
    recipient_email VARCHAR(180) NOT NULL,
    subject VARCHAR(255) NOT NULL,
    status VARCHAR(20) NOT NULL,
    error_message TEXT,
    sent_at DATETIME NOT NULL,
    FOREIGN KEY (tenant_id) REFERENCES tenants(id),
    INDEX idx_recipient (recipient_email),
    INDEX idx_sent_at (sent_at)
);