feat: use mail manager and mail providers

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-07-19 07:08:48 -04:00
parent d8f0b4073c
commit fc3f4c28db
19 changed files with 3622 additions and 2171 deletions
+1 -2
View File
@@ -16,8 +16,7 @@
"vendor-dir": "lib/vendor"
},
"require": {
"php": ">=8.2 <=8.5",
"symfony/mailer": "^7.0"
"php": ">=8.2 <=8.5"
},
"autoload": {
"psr-4": {
Generated
+5 -1048
View File
File diff suppressed because it is too large Load Diff
+4 -8
View File
@@ -8,11 +8,6 @@ use KTXF\Module\ModuleInstanceAbstract;
use KTXF\Resource\Provider\ProviderInterface;
use KTXM\ProviderMailSystem\Providers\Provider;
/**
* SMTP Mail Provider Module
*
* Provides outbound-only mail service via SMTP using Symfony Mailer.
*/
class Module extends ModuleInstanceAbstract implements ModuleBrowserInterface
{
public function __construct(
@@ -36,12 +31,12 @@ class Module extends ModuleInstanceAbstract implements ModuleBrowserInterface
public function description(): string
{
return 'System mail provider module for Ktrix - provides outbound mail delivery via SMTP';
return 'System mail provider module for Ktrix - routes system-generated mail to configured mail services';
}
public function version(): string
{
return '0.0.1';
return '0.0.2';
}
public function permissions(): array
@@ -67,7 +62,8 @@ class Module extends ModuleInstanceAbstract implements ModuleBrowserInterface
'version' => $this->version(),
'label' => $this->label(),
'author' => $this->author(),
'description' => $this->description()
'description' => $this->description(),
'boot' => 'static/module.mjs',
];
}
}
+215 -189
View File
@@ -9,228 +9,254 @@ declare(strict_types=1);
namespace KTXM\ProviderMailSystem\Providers;
use InvalidArgumentException;
use KTXC\Resource\ProviderManager;
use KTXF\Mail\Provider\ProviderBaseInterface;
use KTXF\Mail\Selector\ServiceSelector;
use KTXF\Mail\Service\IServiceBase;
use KTXF\Mail\Service\ServiceScope;
use KTXF\Mail\Provider\ProviderServiceMutateInterface;
use KTXF\Resource\Provider\ResourceServiceMutateInterface;
use KTXM\ProviderMailSystem\Stores\ServiceStore;
use Psr\Log\LoggerInterface;
/**
* SMTP Mail Provider
* System Mail Provider
*
* Provider for SMTP-based mail services. Supports multiple configured
* services per tenant with system and user scopes.
* Rule-based routing provider for system-generated mail. Owns the reserved
* "@system" address namespace: each service is a route that maps a logical
* system address (e.g. authentication@system) to a backing service on a real
* mail provider, with the sender identity rewritten on submission. A
* "*@system" route acts as the tenant-wide default (catch-all).
*
* @since 2025.05.01
* Routes are tenant scoped and resolvable only under the reserved system
* user context.
*
* @since 2026.07.01
*/
class Provider implements ProviderBaseInterface {
class Provider implements ProviderBaseInterface, ProviderServiceMutateInterface
{
private const PROVIDER_ID = 'system';
private const PROVIDER_LABEL = 'System Mail Provider';
private const PROVIDER_DESCRIPTION = 'Outbound-only System mail provider for system notifications';
private const PROVIDER_ICON = 'fa-envelope';
protected const PROVIDER_IDENTIFIER = 'system';
protected const PROVIDER_LABEL = 'System Mail Provider';
protected const PROVIDER_DESCRIPTION = 'Routes system-generated mail to configured mail services';
protected const PROVIDER_ICON = 'mdi-email-lock';
private array $capabilities = [
protected const ADDRESS_DOMAIN_SUFFIX = '@system';
protected array $providerAbilities = [
self::CAPABILITY_SERVICE_LIST => true,
self::CAPABILITY_SERVICE_FETCH => true,
self::CAPABILITY_SERVICE_EXTANT => true,
self::CAPABILITY_SERVICE_CREATE => true,
self::CAPABILITY_SERVICE_MODIFY => true,
self::CAPABILITY_SERVICE_DESTROY => true,
];
public function __construct(
private LoggerInterface $logger,
private ServiceStore $serviceStore,
private readonly ServiceStore $serviceStore,
private readonly ProviderManager $providerManager,
) {}
/**
* @inheritDoc
*/
public function capable(string $value): bool {
return $this->capabilities[$value] ?? false;
}
/**
* @inheritDoc
*/
public function capabilities(): array {
return $this->capabilities;
}
/**
* @inheritDoc
*/
public function id(): string {
return self::PROVIDER_ID;
}
/**
* @inheritDoc
*/
public function label(): string {
return self::PROVIDER_LABEL;
}
/**
* @inheritDoc
*/
public function type(): string {
return self::TYPE_MAIL;
}
/**
* @inheritDoc
*/
public function identifier(): string {
return self::PROVIDER_ID;
}
/**
* @inheritDoc
*/
public function description(): string {
return self::PROVIDER_DESCRIPTION;
}
/**
* @inheritDoc
*/
public function icon(): string {
return self::PROVIDER_ICON;
}
/**
* @inheritDoc
*/
public function serviceList(string $tenantId, string $userId, ?ServiceSelector $selector = null): array {
}
/**
* @inheritDoc
*/
public function serviceExtant(string $tenantId, ?string $userId, string|int ...$identifiers): array {
$result = [];
foreach ($identifiers as $id) {
$result[$id] = $this->serviceFetch($tenantId, $userId, $id) !== null;
}
return $result;
}
/**
* @inheritDoc
*/
public function serviceFetch(string $tenantId, ?string $userId, string|int $identifier): ?IServiceBase {
$identifier = (string)$identifier;
if ($identifier === '') {
return null;
}
// Only handle @system addresses for this provider
if (!str_ends_with(strtolower($identifier), '@system')) {
return null;
}
// Fetch by primary address (which is the sid)
$service = $this->serviceStore->getService($tenantId, $identifier);
if ($service === null) {
return null;
}
// Enforce scope visibility
if ($service->getScope() === ServiceScope::System) {
return $service;
}
if ($service->getScope() === ServiceScope::User) {
if ($userId !== null && $service->getOwner() === $userId) {
return $service;
}
return null;
}
return null;
}
/**
* @inheritDoc
*/
public function serviceFindByAddress(string $tenantId, ?string $userId, string $address): ?IServiceBase {
$address = strtolower(trim($address));
if ($address === '') {
return null;
}
// Only handle @system addresses for this provider
if (!str_ends_with($address, '@system')) {
return null;
}
// Use store's findServiceByAddress which checks primary, secondary, and catch-all patterns
$service = $this->serviceStore->findServiceByAddress($tenantId, $address);
if ($service === null) {
return null;
}
// Enforce scope visibility
if ($service->getScope() === ServiceScope::System) {
return $service;
}
if ($service->getScope() === ServiceScope::User) {
if ($userId !== null && $service->getOwner() === $userId) {
return $service;
}
return null;
}
return null;
}
/**
* @inheritDoc
*/
public function jsonSerialize(): array {
public function jsonSerialize(): array
{
return [
self::JSON_PROPERTY_TYPE => self::JSON_TYPE,
self::JSON_PROPERTY_ID => $this->id(),
self::JSON_PROPERTY_LABEL => $this->label(),
self::JSON_PROPERTY_CAPABILITIES => $this->capabilities(),
self::PROPERTY_TYPE => self::JSON_TYPE,
self::PROPERTY_IDENTIFIER => self::PROVIDER_IDENTIFIER,
self::PROPERTY_LABEL => self::PROVIDER_LABEL,
self::PROPERTY_CAPABILITIES => $this->providerAbilities,
];
}
public function jsonDeserialize(array|string $data): static
{
return $this;
}
public function type(): string
{
return self::TYPE_MAIL;
}
public function identifier(): string
{
return self::PROVIDER_IDENTIFIER;
}
public function label(): string
{
return self::PROVIDER_LABEL;
}
public function description(): string
{
return self::PROVIDER_DESCRIPTION;
}
public function icon(): string
{
return self::PROVIDER_ICON;
}
public function capable(string $value): bool
{
return !empty($this->providerAbilities[$value]);
}
public function capabilities(): array
{
return $this->providerAbilities;
}
public function serviceList(string $tenantId, string $userId, array $filter = []): array
{
// routes are tenant scoped and only visible in the system user context,
// keeping them out of regular user account listings
if ($userId !== self::USER_SYSTEM) {
return [];
}
$list = [];
foreach ($this->serviceStore->list($tenantId, $filter ?: null) as $serviceData) {
$serviceInstance = $this->serviceFresh()->fromStore($serviceData);
$list[$serviceInstance->identifier()] = $serviceInstance;
}
return $list;
}
public function serviceFetch(string $tenantId, string $userId, string|int $identifier): ?Service
{
if ($userId !== self::USER_SYSTEM) {
return null;
}
$serviceData = $this->serviceStore->fetch($tenantId, $identifier);
if ($serviceData === null) {
return null;
}
return $this->serviceFresh()->fromStore($serviceData);
}
/**
* Apply selector filters to services
* Finds the route that handles a logical system address
*
* Only addresses in the reserved "@system" namespace are considered, and
* only when asked in the reserved system user context — real users can
* not resolve (and therefore not send through) system routes. An exact
* address match always wins over the "*@system" catch-all.
*/
private function applySelector(array $services, ServiceSelector $selector): array {
return array_filter($services, function(IServiceBase $service) use ($selector) {
if ($selector->getScope() !== null && $service->getScope() !== $selector->getScope()) {
public function serviceFindByAddress(string $tenantId, string $userId, string $address): ?Service
{
$address = strtolower(trim($address));
if (!str_ends_with($address, self::ADDRESS_DOMAIN_SUFFIX)) {
return null;
}
if ($userId !== self::USER_SYSTEM) {
return null;
}
$catchAll = null;
/** @var Service $service */
foreach ($this->serviceList($tenantId, $userId) as $service) {
if (!$service->getEnabled()) {
continue;
}
if ($service->hasAddressExact($address)) {
return $service;
}
if ($catchAll === null && $service->hasAddressPattern($address)) {
$catchAll = $service;
}
}
return $catchAll;
}
public function serviceExtant(string $tenantId, string $userId, string|int ...$identifiers): array
{
if ($userId !== self::USER_SYSTEM) {
return array_fill_keys(array_map('strval', $identifiers), false);
}
return $this->serviceStore->extant($tenantId, $identifiers);
}
public function serviceFresh(): Service
{
return new Service($this->providerManager);
}
public function serviceCreate(string $tenantId, string $userId, ResourceServiceMutateInterface $service): string
{
$this->assertSystemContext($userId);
if (!($service instanceof Service)) {
throw new InvalidArgumentException('Service must be an instance of System Mail Service');
}
$this->validateRoute($service);
$created = $this->serviceStore->create($tenantId, $service);
return (string)$created['sid'];
}
public function serviceModify(string $tenantId, string $userId, ResourceServiceMutateInterface $service): string
{
$this->assertSystemContext($userId);
if (!($service instanceof Service)) {
throw new InvalidArgumentException('Service must be an instance of System Mail Service');
}
$this->validateRoute($service);
$this->serviceStore->modify($tenantId, $service);
return (string)$service->identifier();
}
public function serviceDestroy(string $tenantId, string $userId, ResourceServiceMutateInterface $service): bool
{
$this->assertSystemContext($userId);
if (!($service instanceof Service)) {
return false;
}
if ($selector->getOwner() !== null && $service->getOwner() !== $selector->getOwner()) {
return false;
return $this->serviceStore->delete($tenantId, $service->identifier());
}
if ($selector->getAddress() !== null && !$service->handlesAddress($selector->getAddress())) {
return false;
}
if ($selector->getCapabilities() !== null) {
foreach ($selector->getCapabilities() as $cap) {
if (!$service->capable($cap)) {
return false;
}
/**
* Ensures the operation runs in the reserved system user context
*
* @throws InvalidArgumentException
*/
protected function assertSystemContext(string $userId): void
{
if ($userId !== self::USER_SYSTEM) {
throw new InvalidArgumentException('System mail routes can only be managed in the system user context');
}
}
if ($selector->getEnabled() !== null && $service->getEnabled() !== $selector->getEnabled()) {
return false;
/**
* Validates route configuration before persisting
*
* @throws InvalidArgumentException
*/
protected function validateRoute(Service $service): void
{
$address = strtolower(trim($service->getPrimaryAddress()->getAddress()));
if ($address === '' || !str_ends_with($address, self::ADDRESS_DOMAIN_SUFFIX)) {
throw new InvalidArgumentException('Route address must be within the reserved "@system" namespace (e.g. authentication@system or *@system)');
}
foreach ($service->getSecondaryAddresses() as $secondary) {
$secondaryAddress = strtolower(trim($secondary->getAddress()));
if ($secondaryAddress === '' || !str_ends_with($secondaryAddress, self::ADDRESS_DOMAIN_SUFFIX)) {
throw new InvalidArgumentException('Route alias addresses must be within the reserved "@system" namespace');
}
}
return true;
});
if ($service->getTargetProvider() === '' || $service->getTargetService() === '') {
throw new InvalidArgumentException('Route requires a delivery target (provider and service)');
}
if ($service->getTargetProvider() === self::PROVIDER_IDENTIFIER) {
throw new InvalidArgumentException('Route cannot target the system provider itself');
}
$fromAddress = $service->getFromAddress();
if ($fromAddress === '' || filter_var($fromAddress, FILTER_VALIDATE_EMAIL) === false) {
throw new InvalidArgumentException('Route requires a valid sender (from) address');
}
}
}
+432 -569
View File
File diff suppressed because it is too large Load Diff
+125 -329
View File
@@ -9,354 +9,150 @@ declare(strict_types=1);
namespace KTXM\ProviderMailSystem\Stores;
use KTXF\Mail\Entity\Address;
use KTXF\Mail\Service\IServiceBase;
use KTXF\Mail\Service\ServiceIdentityBasic;
use KTXF\Mail\Service\ServiceLocation;
use KTXF\Mail\Service\ServiceScope;
use KTXM\ProviderMailSystem\Providers\Service;
use KTXC\Db\DataStore;
use Psr\Log\LoggerInterface;
use KTXF\Utile\UUID;
use KTXM\ProviderMailSystem\Providers\Service;
/**
* Service Store
* System Mail Route Store
*
* @since 2025.05.01
* Persists system mail routes (one MongoDB document per route) in the
* collection `provider_system_mail_services`. Routes are tenant scoped —
* they carry no user association and contain no transport credentials,
* only the mapping from a "@system" address to a backing provider/service.
*
* @since 2026.07.01
*/
class ServiceStore {
class ServiceStore
{
protected const COLLECTION_NAME = 'provider_system_mail_services';
public function __construct(
private DataStore $store,
private LoggerInterface $logger,
protected readonly DataStore $dataStore,
) {}
private string $serviceCollection = 'mail_provider_smtp_service';
/**
* List all services for a tenant
*
* @param string $tenantId
*
* @return array<string|int, Service>
* List routes for a tenant, optionally filtered by route IDs
*/
public function listServices(string $tenantId): array {
try {
$cursor = $this->store->selectCollection($this->serviceCollection)->find([
public function list(string $tenantId, ?array $filter = null): array
{
$filterCondition = [
'tid' => $tenantId,
]);
$services = [];
foreach ($cursor as $entry) {
$id = (string)($entry['sid'] ?? '');
if ($id === '') {
continue;
}
$service = $this->hydrateService($id, is_array($entry) ? $entry : []);
if ($service !== null) {
$services[$id] = $service;
}
}
return $services;
} catch (\Throwable $e) {
$this->logger->warning('Failed to list services', [
'tenantId' => $tenantId,
'error' => $e->getMessage(),
]);
return [];
}
}
/**
* Find a service by address (checks primary, secondary, and catch-all patterns)
*
* @param string $tenantId
* @param string $address Address to search for
*
* @return Service|null
*/
public function findServiceByAddress(string $tenantId, string $address): ?Service {
$address = strtolower(trim($address));
if ($address === '') {
return null;
}
try {
// Get all services for tenant
$services = $this->listServices($tenantId);
foreach ($services as $service) {
if ($service->handlesAddress($address)) {
return $service;
}
}
return null;
} catch (\Throwable $e) {
$this->logger->warning('Failed to find service by address', [
'tenantId' => $tenantId,
'address' => $address,
'error' => $e->getMessage(),
]);
return null;
}
}
/**
* Get a specific service
*
* @param string $tenantId
* @param string|int $serviceId
*
* @return Service|null
*/
public function getService(string $tenantId, string|int $serviceId): ?Service {
$serviceId = (string)$serviceId;
if ($serviceId === '') {
return null;
}
try {
$entry = $this->store->selectCollection($this->serviceCollection)->findOne([
'tid' => $tenantId,
'sid' => $serviceId,
]);
if ($entry === null) {
return null;
}
return $this->hydrateService($serviceId, is_array($entry) ? $entry : []);
} catch (\Throwable $e) {
$this->logger->warning('Failed to fetch service', [
'tenantId' => $tenantId,
'serviceId' => $serviceId,
'error' => $e->getMessage(),
]);
return null;
}
}
/**
* Create a new service
*
* @param string $tenantId
* @param IServiceBase $service
*
* @return string|int Service ID
*/
public function createService(string $tenantId, IServiceBase $service): string|int {
$id = (string)$service->id();
if ($id === '') {
$id = $this->generateServiceId($tenantId);
}
$now = date('c');
$data = $this->dehydrateService($service);
try {
$document = array_merge($data, [
'tid' => $tenantId,
'sid' => $id,
'createdOn' => $now,
'modifiedOn' => $now,
]);
$this->store->selectCollection($this->serviceCollection)->insertOne($document);
return $id;
} catch (\Throwable $e) {
$this->logger->warning('Failed to create service', [
'tenantId' => $tenantId,
'serviceId' => $id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
/**
* Update an existing service
*
* @param string $tenantId
* @param IServiceBase $service
*
* @return string|int Service ID
*/
public function updateService(string $tenantId, IServiceBase $service): string|int {
$id = (string)$service->id();
if ($id === '') {
$id = $this->generateServiceId($tenantId);
}
$now = date('c');
$data = $this->dehydrateService($service);
unset($data['tid'], $data['sid'], $data['createdOn'], $data['modifiedOn']);
try {
$this->store->selectCollection($this->serviceCollection)->updateOne(
['tid' => $tenantId, 'sid' => $id],
[
'$set' => array_merge($data, ['modifiedOn' => $now]),
'$setOnInsert' => ['tid' => $tenantId, 'sid' => $id, 'createdOn' => $now],
],
['upsert' => true]
);
return $id;
} catch (\Throwable $e) {
$this->logger->warning('Failed to update service', [
'tenantId' => $tenantId,
'serviceId' => $id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
/**
* Delete a service
*
* @param string $tenantId
* @param string|int $serviceId
*
* @return bool
*/
public function deleteService(string $tenantId, string|int $serviceId): bool {
$serviceId = (string)$serviceId;
if ($serviceId === '') {
return false;
}
try {
$result = $this->store->selectCollection($this->serviceCollection)->deleteOne([
'tid' => $tenantId,
'sid' => $serviceId,
]);
return $result->getDeletedCount() === 1;
} catch (\Throwable $e) {
$this->logger->warning('Failed to delete service', [
'tenantId' => $tenantId,
'serviceId' => $serviceId,
'error' => $e->getMessage(),
]);
return false;
}
}
/**
* Generate a unique service ID
*/
private function generateServiceId(string $tenantId): string {
// Try a few times to avoid collisions if a unique index is ever added.
for ($attempt = 0; $attempt < 5; $attempt++) {
$id = sprintf('%08x-%04x', time(), mt_rand(0, 0xffff));
try {
$existing = $this->store->selectCollection($this->serviceCollection)->findOne([
'tid' => $tenantId,
'sid' => $id,
], [
'projection' => ['sid' => 1, '_id' => 0],
]);
if ($existing === null) {
return $id;
}
} catch (\Throwable) {
// If the store is unavailable, fall back to generated id.
return $id;
}
}
return sprintf('%08x-%04x', time(), mt_rand(0, 0xffff));
}
/**
* Hydrate a Service from stored data
*/
private function hydrateService(string|int $id, array $data): ?Service {
try {
$service = new Service(
providerId: 'smtp',
id: $id,
label: $data['label'] ?? '',
scope: ServiceScope::tryFrom($data['scope'] ?? 'system') ?? ServiceScope::System,
owner: $data['owner'] ?? null,
enabled: $data['enabled'] ?? true,
);
// Primary address
if (isset($data['primaryAddress'])) {
$service->setPrimaryAddress(Address::fromArray($data['primaryAddress']));
}
// Secondary addresses
if (isset($data['secondaryAddresses']) && is_array($data['secondaryAddresses'])) {
foreach ($data['secondaryAddresses'] as $addrData) {
$service->addSecondaryAddress(Address::fromArray($addrData));
}
}
// Location
if (isset($data['location'])) {
$service->setLocation(ServiceLocation::fromArray($data['location']));
}
// Identity
if (isset($data['identity'])) {
$identityType = $data['identity']['type'] ?? 'basic';
if ($identityType === 'basic') {
$service->setIdentity(ServiceIdentityBasic::fromArray($data['identity']));
}
}
return $service;
} catch (\Throwable $e) {
$this->logger->warning('Failed to hydrate service', [
'id' => $id,
'error' => $e->getMessage(),
]);
return null;
}
}
/**
* Dehydrate a Service to storable data
*/
private function dehydrateService(IServiceBase $service): array {
$data = [
'label' => $service->getLabel(),
'scope' => $service->getScope()->value,
'owner' => $service->getOwner(),
'enabled' => $service->getEnabled(),
'primaryAddress' => $service->getPrimaryAddress()->jsonSerialize(),
'secondaryAddresses' => array_map(
fn($a) => $a->jsonSerialize(),
$service->getSecondaryAddresses()
),
];
// Store location if it's a Service instance
if ($service instanceof Service) {
$location = $service->getLocation();
if ($location !== null) {
$data['location'] = $location->jsonSerialize();
if (!empty($filter)) {
$filterCondition['sid'] = ['$in' => array_map('strval', $filter)];
}
$identity = $service->getIdentity();
if ($identity !== null) {
$identityData = $identity->jsonSerialize();
// Include password for storage (it's excluded from default serialization)
if ($identity instanceof ServiceIdentityBasic) {
$identityData['password'] = $identity->getPassword();
}
$data['identity'] = $identityData;
$cursor = $this->dataStore->selectCollection(self::COLLECTION_NAME)->find($filterCondition);
$list = [];
foreach ($cursor as $entry) {
$list[$entry['sid']] = $entry;
}
return $list;
}
return $data;
/**
* Check existence of routes by IDs for a tenant
*/
public function extant(string $tenantId, array $identifiers): array
{
if (empty($identifiers)) {
return [];
}
$cursor = $this->dataStore->selectCollection(self::COLLECTION_NAME)->find(
[
'tid' => $tenantId,
'sid' => ['$in' => array_map('strval', $identifiers)]
],
['projection' => ['sid' => 1]]
);
$existingIds = [];
foreach ($cursor as $document) {
$existingIds[] = $document['sid'];
}
$result = [];
foreach ($identifiers as $id) {
$result[(string)$id] = in_array((string)$id, $existingIds, true);
}
return $result;
}
/**
* Retrieve a single route by ID
*/
public function fetch(string $tenantId, string|int $serviceId): ?array
{
$document = $this->dataStore->selectCollection(self::COLLECTION_NAME)->findOne([
'tid' => $tenantId,
'sid' => (string)$serviceId,
]);
if (!$document) {
return null;
}
return $document;
}
/**
* Create a new route
*/
public function create(string $tenantId, Service $service): array
{
$document = $service->toStore();
$document['tid'] = $tenantId;
$document['sid'] = UUID::v4();
$document['createdOn'] = new \MongoDB\BSON\UTCDateTime();
$document['modifiedOn'] = new \MongoDB\BSON\UTCDateTime();
$this->dataStore->selectCollection(self::COLLECTION_NAME)->insertOne($document);
return $document;
}
/**
* Modify an existing route
*/
public function modify(string $tenantId, Service $service): array
{
$serviceId = (string)$service->identifier();
if ($serviceId === '') {
throw new \InvalidArgumentException('Service ID is required for update');
}
$document = $service->toStore();
$document['modifiedOn'] = new \MongoDB\BSON\UTCDateTime();
unset($document['sid'], $document['tid'], $document['createdOn']);
$this->dataStore->selectCollection(self::COLLECTION_NAME)->updateOne(
[
'tid' => $tenantId,
'sid' => $serviceId,
],
['$set' => $document]
);
return $document;
}
/**
* Delete a route
*/
public function delete(string $tenantId, string|int $serviceId): bool
{
$result = $this->dataStore->selectCollection(self::COLLECTION_NAME)->deleteOne([
'tid' => $tenantId,
'sid' => (string)$serviceId,
]);
return $result->getDeletedCount() > 0;
}
}
+1908
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
{
"name": "provider_mail_system",
"description": "Ktrix System Mail Provider Module",
"version": "1.0.0",
"private": true,
"license": "AGPL-3.0-or-later",
"author": "Ktrix",
"type": "module",
"scripts": {
"build": "vite build --mode production --config vite.config.ts",
"dev": "vite build --mode development --config vite.config.ts",
"watch": "vite build --mode development --watch --config vite.config.ts",
"typecheck": "vue-tsc --noEmit",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
},
"dependencies": {
"pinia": "^3.0.0",
"vue": "^3.5.18",
"vue-router": "^5.0.0",
"vuetify": "^4.0.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"@vue/tsconfig": "^0.9.0",
"typescript": "~6.0.0",
"vite": "^8.0.0",
"vue-tsc": "^3.0.5"
}
}
+265
View File
@@ -0,0 +1,265 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { serviceService } from '@KTXM/MailManager/main'
import type { ServiceObject } from '@KTXM/MailManager/models/service'
const SYSTEM_USER = 'system'
const SYSTEM_PROVIDER = 'system'
const SYSTEM_DOMAIN = '@system'
const props = defineProps<{
modelValue: boolean
rule: ServiceObject | null
accountOptions: { title: string; value: string }[]
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'saved': []
}>()
const dialogOpen = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
const isEdit = computed(() => props.rule !== null)
const saving = ref(false)
const saveError = ref<string | null>(null)
const formValid = ref(false)
// Form fields
const label = ref('')
const localPart = ref('')
const catchAll = ref(false)
const targetAccount = ref<string | null>(null)
const fromAddress = ref('')
const fromLabel = ref('')
const enabled = ref(true)
const systemAddress = computed(() =>
catchAll.value ? `*${SYSTEM_DOMAIN}` : `${localPart.value.trim().toLowerCase()}${SYSTEM_DOMAIN}`
)
const localPartRules = [
(v: string) => catchAll.value || !!v?.trim() || 'Function name is required',
(v: string) => catchAll.value || /^[a-z0-9._-]+$/i.test(v?.trim() || '') || 'Only letters, numbers, dots, dashes and underscores',
]
const targetRules = [
(v: string | null) => !!v || 'A delivery account is required',
]
const fromAddressRules = [
(v: string) => !!v?.trim() || 'Sender address is required',
(v: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v?.trim() || '') || 'Must be a valid email address',
]
// Populate the form when the dialog opens
watch(dialogOpen, (isOpen) => {
if (!isOpen) {
return
}
saveError.value = null
if (props.rule) {
const address = props.rule.primaryAddress?.address || ''
label.value = props.rule.label || ''
catchAll.value = address.startsWith('*@')
localPart.value = catchAll.value ? '' : address.replace(SYSTEM_DOMAIN, '')
const aux = props.rule.auxiliary || {}
targetAccount.value = aux.targetProvider && aux.targetService
? `${aux.targetProvider}:${aux.targetService}`
: null
fromAddress.value = aux.fromAddress || ''
fromLabel.value = aux.fromLabel || ''
enabled.value = props.rule.enabled
} else {
label.value = ''
localPart.value = ''
catchAll.value = false
targetAccount.value = null
fromAddress.value = ''
fromLabel.value = ''
enabled.value = true
}
})
async function save() {
if (!formValid.value || !targetAccount.value) {
return
}
const separatorIndex = targetAccount.value.indexOf(':')
const targetProvider = targetAccount.value.slice(0, separatorIndex)
const targetService = targetAccount.value.slice(separatorIndex + 1)
const data = {
label: label.value.trim() || systemAddress.value,
enabled: enabled.value,
primaryAddress: { address: systemAddress.value },
auxiliary: {
targetProvider,
targetService,
fromAddress: fromAddress.value.trim(),
fromLabel: fromLabel.value.trim() || null,
},
}
saving.value = true
saveError.value = null
try {
if (isEdit.value && props.rule) {
await serviceService.update(
{
provider: SYSTEM_PROVIDER,
identifier: props.rule.identifier as string | number,
delta: false,
data,
},
SYSTEM_USER
)
} else {
await serviceService.create({ provider: SYSTEM_PROVIDER, data }, SYSTEM_USER)
}
emit('saved')
} catch (error: any) {
console.error('[System Mail] Failed to save rule:', error)
saveError.value = error.message || 'Failed to save rule'
} finally {
saving.value = false
}
}
function close() {
dialogOpen.value = false
}
</script>
<template>
<v-dialog v-model="dialogOpen" max-width="600" persistent scrollable>
<v-card>
<v-card-title class="d-flex justify-space-between align-center pa-6">
<span class="text-h5">{{ isEdit ? 'Edit Routing Rule' : 'Add Routing Rule' }}</span>
<v-btn icon="mdi-close" variant="text" @click="close" />
</v-card-title>
<v-divider />
<v-card-text class="pa-6">
<v-form v-model="formValid" @submit.prevent="save">
<v-alert v-if="saveError" type="error" variant="tonal" class="mb-4">
{{ saveError }}
</v-alert>
<v-text-field
v-model="label"
label="Label"
placeholder="e.g. Authentication mail"
variant="outlined"
density="comfortable"
class="mb-2"
hint="Optional display name for this rule"
persistent-hint
/>
<v-switch
v-model="catchAll"
label="Default rule (catch-all)"
color="primary"
density="comfortable"
hint="Handles every system address that has no dedicated rule"
persistent-hint
class="mb-2"
/>
<v-text-field
v-if="!catchAll"
v-model="localPart"
label="Function"
placeholder="e.g. authentication, notification, billing"
:suffix="SYSTEM_DOMAIN"
:rules="localPartRules"
variant="outlined"
density="comfortable"
class="mb-2"
hint="The logical address consumers send from"
persistent-hint
/>
<v-text-field
v-else
:model-value="`*${SYSTEM_DOMAIN}`"
label="Address"
variant="outlined"
density="comfortable"
class="mb-2"
readonly
disabled
/>
<v-select
v-model="targetAccount"
:items="accountOptions"
:rules="targetRules"
label="Deliver via account"
variant="outlined"
density="comfortable"
class="mb-2"
hint="The system account used for actual delivery"
persistent-hint
/>
<v-text-field
v-model="fromAddress"
label="Send as (From address)"
placeholder="e.g. no-reply@example.com"
:rules="fromAddressRules"
variant="outlined"
density="comfortable"
class="mb-2"
hint="The real address recipients will see"
persistent-hint
/>
<v-text-field
v-model="fromLabel"
label="Sender name"
placeholder="e.g. Example Security"
variant="outlined"
density="comfortable"
class="mb-2"
hint="Optional display name for the sender"
persistent-hint
/>
<v-switch
v-model="enabled"
label="Enabled"
color="primary"
density="comfortable"
/>
</v-form>
</v-card-text>
<v-divider />
<v-card-actions class="pa-6">
<v-spacer />
<v-btn variant="text" @click="close">Cancel</v-btn>
<v-btn
color="primary"
:loading="saving"
:disabled="!formValid"
@click="save"
>
<v-icon start>mdi-content-save</v-icon>
{{ isEdit ? 'Save Changes' : 'Add Rule' }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
+24
View File
@@ -0,0 +1,24 @@
import type { ModuleIntegrations } from '@KTXC/types/moduleTypes'
const integrations: ModuleIntegrations = {
admin_settings_menu: [
{
id: 'system_mail',
label: 'System Mail',
path: '/system-mail',
icon: 'mdi-email-lock',
priority: 70,
caption: 'Manage system mail accounts and routing rules',
},
],
mail_provider_details: [
{
id: 'system',
label: 'System Mail',
description: 'Rule-based routing for system-generated mail',
icon: 'mdi-email-lock',
},
],
}
export default integrations
+13
View File
@@ -0,0 +1,13 @@
import '@/style.css'
import routes from '@/routes'
import integrations from '@/integrations'
import type { App as VueApp } from 'vue'
export const css = ['__CSS_FILENAME_PLACEHOLDER__']
export { routes, integrations }
export default {
install(_app: VueApp) {
}
}
+446
View File
@@ -0,0 +1,446 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import {
AddAccountDialog,
EditAccountDialog,
serviceService,
providerService,
} from '@KTXM/MailManager/main'
import type { ServiceObject } from '@KTXM/MailManager/models/service'
import type { ProviderObject } from '@KTXM/MailManager/models/provider'
import RuleDialog from '@/components/RuleDialog.vue'
const SYSTEM_USER = 'system'
const SYSTEM_PROVIDER = 'system'
const loading = ref(false)
const loadError = ref<string | null>(null)
const providers = ref<Record<string, ProviderObject>>({})
const rules = ref<ServiceObject[]>([])
const accounts = ref<ServiceObject[]>([])
const showAddAccountDialog = ref(false)
const showEditAccountDialog = ref(false)
const showRuleDialog = ref(false)
const showDeleteConfirm = ref(false)
const showResult = ref(false)
const selectedAccount = ref<ServiceObject | null>(null)
const selectedRule = ref<ServiceObject | null>(null)
const deleteTarget = ref<ServiceObject | null>(null)
const deleteKind = ref<'rule' | 'account'>('rule')
const deleting = ref(false)
const testingId = ref<string | number | null>(null)
const resultMessage = ref<{ success: boolean; message: string } | null>(null)
const hasRules = computed(() => rules.value.length > 0)
const hasAccounts = computed(() => accounts.value.length > 0)
const accountOptions = computed(() =>
accounts.value.map(account => ({
title: `${account.label || account.primaryAddress?.address || account.identifier} (${providerLabel(account.provider)})`,
value: `${account.provider}:${account.identifier}`,
}))
)
onMounted(load)
async function load() {
loading.value = true
loadError.value = null
try {
const [providerList, serviceList] = await Promise.all([
providerService.list(),
serviceService.list({}, SYSTEM_USER),
])
providers.value = providerList
const ruleList: ServiceObject[] = []
const accountList: ServiceObject[] = []
Object.entries(serviceList).forEach(([providerId, services]) => {
Object.values(services).forEach(service => {
if (providerId === SYSTEM_PROVIDER) {
ruleList.push(service)
} else {
accountList.push(service)
}
})
})
rules.value = ruleList
accounts.value = accountList
} catch (error: any) {
console.error('[System Mail] Failed to load:', error)
loadError.value = error.message || 'Failed to load system mail configuration'
} finally {
loading.value = false
}
}
function providerLabel(providerId: string): string {
return providers.value[providerId]?.label || providerId.toUpperCase()
}
function ruleAddress(rule: ServiceObject): string {
return rule.primaryAddress?.address || ''
}
function ruleIsCatchAll(rule: ServiceObject): boolean {
return ruleAddress(rule).startsWith('*@')
}
function ruleTargetLabel(rule: ServiceObject): string {
const targetProvider = rule.auxiliary?.targetProvider
const targetService = rule.auxiliary?.targetService
if (!targetProvider || !targetService) {
return 'Not configured'
}
const account = accounts.value.find(
a => a.provider === targetProvider && String(a.identifier) === String(targetService)
)
return account
? `${account.label || account.primaryAddress?.address || targetService} (${providerLabel(targetProvider)})`
: `${targetProvider}:${targetService} (missing)`
}
// ==================== Rules ====================
function addRule() {
selectedRule.value = null
showRuleDialog.value = true
}
function editRule(rule: ServiceObject) {
selectedRule.value = rule
showRuleDialog.value = true
}
// ==================== Accounts ====================
function editAccount(account: ServiceObject) {
selectedAccount.value = account
showEditAccountDialog.value = true
}
async function testAccount(account: ServiceObject) {
testingId.value = account.identifier
try {
const result = await serviceService.test(
{ provider: account.provider, identifier: account.identifier as string | number },
SYSTEM_USER
)
resultMessage.value = result
} catch (error: any) {
resultMessage.value = { success: false, message: error.message || 'Connection test failed' }
} finally {
testingId.value = null
showResult.value = true
}
}
// ==================== Delete ====================
function confirmDelete(target: ServiceObject, kind: 'rule' | 'account') {
deleteTarget.value = target
deleteKind.value = kind
showDeleteConfirm.value = true
}
async function performDelete() {
if (!deleteTarget.value) return
deleting.value = true
try {
await serviceService.delete(
{
provider: deleteTarget.value.provider,
identifier: deleteTarget.value.identifier as string | number,
},
SYSTEM_USER
)
showDeleteConfirm.value = false
deleteTarget.value = null
await load()
} catch (error: any) {
resultMessage.value = { success: false, message: error.message || 'Delete failed' }
showResult.value = true
} finally {
deleting.value = false
}
}
async function handleSaved() {
showRuleDialog.value = false
showAddAccountDialog.value = false
showEditAccountDialog.value = false
await load()
}
</script>
<template>
<v-container fluid>
<!-- Page Header -->
<div class="d-flex align-center justify-space-between mb-6">
<div>
<h1 class="text-h4 mb-1">System Mail</h1>
<p class="text-body-2 text-medium-emphasis">
Route system-generated mail (verification codes, password resets, notifications)
through dedicated sending accounts
</p>
</div>
</div>
<!-- Loading State -->
<v-row v-if="loading" class="mt-4">
<v-col v-for="i in 2" :key="i" cols="12" md="6">
<v-skeleton-loader type="card" />
</v-col>
</v-row>
<v-alert v-else-if="loadError" type="error" variant="tonal" class="mb-6">
{{ loadError }}
</v-alert>
<template v-else>
<!-- ==================== Routing Rules ==================== -->
<div class="d-flex align-center justify-space-between mb-4">
<div class="d-flex align-center">
<v-icon icon="mdi-routes" class="mr-2" />
<h2 class="text-h6">Routing Rules</h2>
<v-chip size="small" class="ml-2" variant="text">
{{ rules.length }} rule{{ rules.length !== 1 ? 's' : '' }}
</v-chip>
</div>
<v-btn
color="primary"
prepend-icon="mdi-plus"
:disabled="!hasAccounts"
@click="addRule"
>
Add Rule
</v-btn>
</div>
<v-card v-if="!hasRules" class="text-center pa-8 mb-8" variant="flat">
<v-icon size="64" color="grey-lighten-1" class="mb-3">mdi-routes</v-icon>
<h3 class="text-h6 mb-1">No Routing Rules</h3>
<p class="text-body-2 text-medium-emphasis mb-0">
Add a rule to map a system address (e.g. <code>authentication@system</code>)
to a sending account. A <code>*@system</code> rule acts as the default for
all unmatched system addresses.
<template v-if="!hasAccounts"><br>Create a system account first.</template>
</p>
</v-card>
<v-row v-else class="mb-6">
<v-col
v-for="rule in rules"
:key="String(rule.identifier)"
cols="12"
md="6"
lg="4"
>
<v-card :class="{ 'border-error': !rule.enabled }" variant="outlined" hover>
<v-card-text>
<div class="d-flex align-center justify-space-between mb-2">
<div class="d-flex align-center">
<v-avatar size="40" :color="rule.enabled ? 'primary' : 'grey'" class="mr-3">
<v-icon color="white">
{{ ruleIsCatchAll(rule) ? 'mdi-asterisk' : 'mdi-email-fast' }}
</v-icon>
</v-avatar>
<div>
<h3 class="text-h6">{{ rule.label || ruleAddress(rule) }}</h3>
<p class="text-caption text-medium-emphasis mb-0">
{{ ruleAddress(rule) }}
<v-chip v-if="ruleIsCatchAll(rule)" size="x-small" class="ml-1" variant="tonal">
default
</v-chip>
</p>
</div>
</div>
<v-chip :color="rule.enabled ? 'success' : 'error'" size="small" variant="flat">
{{ rule.enabled ? 'Enabled' : 'Disabled' }}
</v-chip>
</div>
<v-divider class="my-3" />
<div class="text-caption text-medium-emphasis">
<div class="d-flex align-center mb-1">
<v-icon size="small" class="mr-1">mdi-send</v-icon>
Delivers via: {{ ruleTargetLabel(rule) }}
</div>
<div class="d-flex align-center">
<v-icon size="small" class="mr-1">mdi-account-arrow-right</v-icon>
Sends as: {{ rule.auxiliary?.fromAddress || 'Not configured' }}
</div>
</div>
</v-card-text>
<v-card-actions>
<v-btn variant="text" size="small" prepend-icon="mdi-pencil" @click="editRule(rule)">
Edit
</v-btn>
<v-spacer />
<v-btn
variant="text"
size="small"
color="error"
icon="mdi-delete"
@click="confirmDelete(rule, 'rule')"
/>
</v-card-actions>
</v-card>
</v-col>
</v-row>
<!-- ==================== System Accounts ==================== -->
<div class="d-flex align-center justify-space-between mb-4">
<div class="d-flex align-center">
<v-icon icon="mdi-email-lock" class="mr-2" />
<h2 class="text-h6">System Accounts</h2>
<v-chip size="small" class="ml-2" variant="text">
{{ accounts.length }} account{{ accounts.length !== 1 ? 's' : '' }}
</v-chip>
</div>
<v-btn color="primary" prepend-icon="mdi-plus" @click="showAddAccountDialog = true">
Add Account
</v-btn>
</div>
<v-card v-if="!hasAccounts" class="text-center pa-8" variant="flat">
<v-icon size="64" color="grey-lighten-1" class="mb-3">mdi-email-off-outline</v-icon>
<h3 class="text-h6 mb-1">No System Accounts</h3>
<p class="text-body-2 text-medium-emphasis mb-0">
System accounts are dedicated sending accounts owned by this tenant.
Routing rules deliver system mail through them.
</p>
</v-card>
<v-row v-else>
<v-col
v-for="account in accounts"
:key="`${account.provider}:${account.identifier}`"
cols="12"
md="6"
lg="4"
>
<v-card :class="{ 'border-error': !account.enabled }" variant="outlined" hover>
<v-card-text>
<div class="d-flex align-center justify-space-between mb-2">
<div class="d-flex align-center">
<v-avatar size="40" :color="account.enabled ? 'primary' : 'grey'" class="mr-3">
<v-icon color="white">
{{ account.enabled ? 'mdi-email' : 'mdi-email-off' }}
</v-icon>
</v-avatar>
<div>
<h3 class="text-h6">{{ account.label }}</h3>
<p class="text-caption text-medium-emphasis mb-0">
{{ account.primaryAddress?.address || 'No email configured' }}
</p>
</div>
</div>
<v-chip :color="account.enabled ? 'success' : 'error'" size="small" variant="flat">
{{ account.enabled ? 'Enabled' : 'Disabled' }}
</v-chip>
</div>
<v-divider class="my-3" />
<div class="text-caption text-medium-emphasis">
<div class="d-flex align-center">
<v-icon size="small" class="mr-1">mdi-connection</v-icon>
{{ providerLabel(account.provider) }}
</div>
</div>
</v-card-text>
<v-card-actions>
<v-btn variant="text" size="small" prepend-icon="mdi-pencil" @click="editAccount(account)">
Edit
</v-btn>
<v-btn
variant="text"
size="small"
prepend-icon="mdi-connection"
:loading="testingId === account.identifier"
@click="testAccount(account)"
>
Test
</v-btn>
<v-spacer />
<v-btn
variant="text"
size="small"
color="error"
icon="mdi-delete"
@click="confirmDelete(account, 'account')"
/>
</v-card-actions>
</v-card>
</v-col>
</v-row>
</template>
<!-- Rule Dialog -->
<RuleDialog
v-model="showRuleDialog"
:rule="selectedRule"
:account-options="accountOptions"
@saved="handleSaved"
/>
<!-- Add Account Dialog (system user context) -->
<AddAccountDialog
v-model="showAddAccountDialog"
user="system"
@saved="handleSaved"
/>
<!-- Edit Account Dialog (system user context) -->
<EditAccountDialog
v-model="showEditAccountDialog"
:service-provider="selectedAccount?.provider || ''"
:service-identifier="selectedAccount?.identifier || ''"
user="system"
@saved="handleSaved"
/>
<!-- Delete Confirmation Dialog -->
<v-dialog v-model="showDeleteConfirm" max-width="400">
<v-card>
<v-card-title class="text-h6">
Delete {{ deleteKind === 'rule' ? 'Rule' : 'Account' }}?
</v-card-title>
<v-card-text>
Are you sure you want to delete
<strong>{{ deleteTarget?.label || deleteTarget?.identifier }}</strong>?
<template v-if="deleteKind === 'account'">
Routing rules targeting this account will stop delivering.
</template>
This action cannot be undone.
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showDeleteConfirm = false">Cancel</v-btn>
<v-btn color="error" variant="flat" :loading="deleting" @click="performDelete">
Delete
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Result Snackbar -->
<v-snackbar
v-model="showResult"
:color="resultMessage?.success ? 'success' : 'error'"
:timeout="5000"
>
<v-icon start>
{{ resultMessage?.success ? 'mdi-check-circle' : 'mdi-alert-circle' }}
</v-icon>
{{ resultMessage?.message || 'Operation completed' }}
</v-snackbar>
</v-container>
</template>
+14
View File
@@ -0,0 +1,14 @@
const routes = [
{
name: 'system-mail',
path: '/system-mail',
component: () => import('@/pages/Main.vue'),
meta: {
title: 'System Mail',
requiresAuth: true,
permission: 'mail_manager.system',
}
},
]
export default routes
+1
View File
@@ -0,0 +1 @@
/* System Mail Provider module styles */
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+18
View File
@@ -0,0 +1,18 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"paths": {
"@/*": ["./src/*"],
"@KTXC/*": ["../../core/src/*"],
"@KTXM/MailManager/*": ["../mail_manager/src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
+67
View File
@@ -0,0 +1,67 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
export default defineConfig({
plugins: [
vue(),
{
name: 'inject-css-filename',
enforce: 'post',
generateBundle(_options, bundle) {
const cssFile = Object.keys(bundle).find(name => name.endsWith('.css'))
if (!cssFile) {
return
}
for (const fileName of Object.keys(bundle)) {
const chunk = bundle[fileName]
if (chunk.type === 'chunk' && chunk.code.includes('__CSS_FILENAME_PLACEHOLDER__')) {
chunk.code = chunk.code.replace(/__CSS_FILENAME_PLACEHOLDER__/g, `static/${cssFile}`)
}
}
}
}
],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'@KTXC': path.resolve(__dirname, '../../core/src'),
'@KTXM/MailManager': path.resolve(__dirname, '../mail_manager/src'),
},
},
build: {
outDir: 'static',
emptyOutDir: true,
sourcemap: true,
lib: {
entry: path.resolve(__dirname, 'src/main.ts'),
formats: ['es'],
fileName: () => 'module.mjs',
},
rollupOptions: {
external: [
'vue',
'vue-router',
'pinia',
/^@KTXM\/MailManager\//,
],
output: {
paths: (id) => {
if (id.startsWith('@KTXM/MailManager/')) {
return '/modules/mail_manager/static/module.mjs'
}
return id
},
assetFileNames: assetInfo => {
if (assetInfo.name?.endsWith('.css')) {
return 'provider_mail_system-[hash].css'
}
return '[name]-[hash][extname]'
}
}
},
},
})