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)
);