feat: use mail manager and mail providers
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
+1
-2
@@ -16,8 +16,7 @@
|
|||||||
"vendor-dir": "lib/vendor"
|
"vendor-dir": "lib/vendor"
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
"php": ">=8.2 <=8.5",
|
"php": ">=8.2 <=8.5"
|
||||||
"symfony/mailer": "^7.0"
|
|
||||||
},
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
|
|||||||
Generated
+5
-1048
File diff suppressed because it is too large
Load Diff
+5
-9
@@ -8,11 +8,6 @@ use KTXF\Module\ModuleInstanceAbstract;
|
|||||||
use KTXF\Resource\Provider\ProviderInterface;
|
use KTXF\Resource\Provider\ProviderInterface;
|
||||||
use KTXM\ProviderMailSystem\Providers\Provider;
|
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
|
class Module extends ModuleInstanceAbstract implements ModuleBrowserInterface
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
@@ -36,12 +31,12 @@ class Module extends ModuleInstanceAbstract implements ModuleBrowserInterface
|
|||||||
|
|
||||||
public function description(): string
|
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
|
public function version(): string
|
||||||
{
|
{
|
||||||
return '0.0.1';
|
return '0.0.2';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function permissions(): array
|
public function permissions(): array
|
||||||
@@ -54,7 +49,7 @@ class Module extends ModuleInstanceAbstract implements ModuleBrowserInterface
|
|||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
$this->providerManager->register(ProviderInterface::TYPE_MAIL, 'system', Provider::class);
|
$this->providerManager->register(ProviderInterface::TYPE_MAIL, 'system', Provider::class);
|
||||||
@@ -67,7 +62,8 @@ class Module extends ModuleInstanceAbstract implements ModuleBrowserInterface
|
|||||||
'version' => $this->version(),
|
'version' => $this->version(),
|
||||||
'label' => $this->label(),
|
'label' => $this->label(),
|
||||||
'author' => $this->author(),
|
'author' => $this->author(),
|
||||||
'description' => $this->description()
|
'description' => $this->description(),
|
||||||
|
'boot' => 'static/module.mjs',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+226
-200
@@ -9,228 +9,254 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace KTXM\ProviderMailSystem\Providers;
|
namespace KTXM\ProviderMailSystem\Providers;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use KTXC\Resource\ProviderManager;
|
||||||
use KTXF\Mail\Provider\ProviderBaseInterface;
|
use KTXF\Mail\Provider\ProviderBaseInterface;
|
||||||
use KTXF\Mail\Selector\ServiceSelector;
|
use KTXF\Mail\Provider\ProviderServiceMutateInterface;
|
||||||
use KTXF\Mail\Service\IServiceBase;
|
use KTXF\Resource\Provider\ResourceServiceMutateInterface;
|
||||||
use KTXF\Mail\Service\ServiceScope;
|
|
||||||
use KTXM\ProviderMailSystem\Stores\ServiceStore;
|
use KTXM\ProviderMailSystem\Stores\ServiceStore;
|
||||||
use Psr\Log\LoggerInterface;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SMTP Mail Provider
|
* System Mail Provider
|
||||||
*
|
*
|
||||||
* Provider for SMTP-based mail services. Supports multiple configured
|
* Rule-based routing provider for system-generated mail. Owns the reserved
|
||||||
* services per tenant with system and user scopes.
|
* "@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
|
||||||
* @since 2025.05.01
|
* mail provider, with the sender identity rewritten on submission. A
|
||||||
|
* "*@system" route acts as the tenant-wide default (catch-all).
|
||||||
|
*
|
||||||
|
* 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';
|
protected const PROVIDER_IDENTIFIER = 'system';
|
||||||
private const PROVIDER_LABEL = 'System Mail Provider';
|
protected const PROVIDER_LABEL = 'System Mail Provider';
|
||||||
private const PROVIDER_DESCRIPTION = 'Outbound-only System mail provider for system notifications';
|
protected const PROVIDER_DESCRIPTION = 'Routes system-generated mail to configured mail services';
|
||||||
private const PROVIDER_ICON = 'fa-envelope';
|
protected const PROVIDER_ICON = 'mdi-email-lock';
|
||||||
|
|
||||||
private array $capabilities = [
|
protected const ADDRESS_DOMAIN_SUFFIX = '@system';
|
||||||
self::CAPABILITY_SERVICE_LIST => true,
|
|
||||||
self::CAPABILITY_SERVICE_FETCH => true,
|
protected array $providerAbilities = [
|
||||||
self::CAPABILITY_SERVICE_EXTANT => true,
|
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(
|
public function __construct(
|
||||||
private LoggerInterface $logger,
|
private readonly ServiceStore $serviceStore,
|
||||||
private ServiceStore $serviceStore,
|
private readonly ProviderManager $providerManager,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
public function jsonSerialize(): array
|
||||||
* @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 {
|
|
||||||
return [
|
return [
|
||||||
self::JSON_PROPERTY_TYPE => self::JSON_TYPE,
|
self::PROPERTY_TYPE => self::JSON_TYPE,
|
||||||
self::JSON_PROPERTY_ID => $this->id(),
|
self::PROPERTY_IDENTIFIER => self::PROVIDER_IDENTIFIER,
|
||||||
self::JSON_PROPERTY_LABEL => $this->label(),
|
self::PROPERTY_LABEL => self::PROVIDER_LABEL,
|
||||||
self::JSON_PROPERTY_CAPABILITIES => $this->capabilities(),
|
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 {
|
public function serviceFindByAddress(string $tenantId, string $userId, string $address): ?Service
|
||||||
return array_filter($services, function(IServiceBase $service) use ($selector) {
|
{
|
||||||
if ($selector->getScope() !== null && $service->getScope() !== $selector->getScope()) {
|
$address = strtolower(trim($address));
|
||||||
return false;
|
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)) {
|
||||||
if ($selector->getOwner() !== null && $service->getOwner() !== $selector->getOwner()) {
|
return $service;
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
if ($catchAll === null && $service->hasAddressPattern($address)) {
|
||||||
if ($selector->getAddress() !== null && !$service->handlesAddress($selector->getAddress())) {
|
$catchAll = $service;
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if ($selector->getCapabilities() !== null) {
|
|
||||||
foreach ($selector->getCapabilities() as $cap) {
|
return $catchAll;
|
||||||
if (!$service->capable($cap)) {
|
}
|
||||||
return false;
|
|
||||||
}
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->serviceStore->delete($tenantId, $service->identifier());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if ($selector->getEnabled() !== null && $service->getEnabled() !== $selector->getEnabled()) {
|
|
||||||
return false;
|
if ($service->getTargetProvider() === '' || $service->getTargetService() === '') {
|
||||||
}
|
throw new InvalidArgumentException('Route requires a delivery target (provider and service)');
|
||||||
|
}
|
||||||
return true;
|
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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+462
-599
File diff suppressed because it is too large
Load Diff
+109
-313
@@ -9,354 +9,150 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace KTXM\ProviderMailSystem\Stores;
|
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 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(
|
public function __construct(
|
||||||
private DataStore $store,
|
protected readonly DataStore $dataStore,
|
||||||
private LoggerInterface $logger,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private string $serviceCollection = 'mail_provider_smtp_service';
|
/**
|
||||||
|
* List routes for a tenant, optionally filtered by route IDs
|
||||||
|
*/
|
||||||
|
public function list(string $tenantId, ?array $filter = null): array
|
||||||
|
{
|
||||||
|
$filterCondition = [
|
||||||
|
'tid' => $tenantId,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!empty($filter)) {
|
||||||
|
$filterCondition['sid'] = ['$in' => array_map('strval', $filter)];
|
||||||
|
}
|
||||||
|
|
||||||
|
$cursor = $this->dataStore->selectCollection(self::COLLECTION_NAME)->find($filterCondition);
|
||||||
|
|
||||||
|
$list = [];
|
||||||
|
foreach ($cursor as $entry) {
|
||||||
|
$list[$entry['sid']] = $entry;
|
||||||
|
}
|
||||||
|
return $list;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List all services for a tenant
|
* Check existence of routes by IDs for a tenant
|
||||||
*
|
|
||||||
* @param string $tenantId
|
|
||||||
*
|
|
||||||
* @return array<string|int, Service>
|
|
||||||
*/
|
*/
|
||||||
public function listServices(string $tenantId): array {
|
public function extant(string $tenantId, array $identifiers): array
|
||||||
try {
|
{
|
||||||
$cursor = $this->store->selectCollection($this->serviceCollection)->find([
|
if (empty($identifiers)) {
|
||||||
'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 [];
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find a service by address (checks primary, secondary, and catch-all patterns)
|
* Retrieve a single route by ID
|
||||||
*
|
|
||||||
* @param string $tenantId
|
|
||||||
* @param string $address Address to search for
|
|
||||||
*
|
|
||||||
* @return Service|null
|
|
||||||
*/
|
*/
|
||||||
public function findServiceByAddress(string $tenantId, string $address): ?Service {
|
public function fetch(string $tenantId, string|int $serviceId): ?array
|
||||||
$address = strtolower(trim($address));
|
{
|
||||||
if ($address === '') {
|
$document = $this->dataStore->selectCollection(self::COLLECTION_NAME)->findOne([
|
||||||
|
'tid' => $tenantId,
|
||||||
|
'sid' => (string)$serviceId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!$document) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
return $document;
|
||||||
// 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
|
* Create a new route
|
||||||
*
|
|
||||||
* @param string $tenantId
|
|
||||||
* @param string|int $serviceId
|
|
||||||
*
|
|
||||||
* @return Service|null
|
|
||||||
*/
|
*/
|
||||||
public function getService(string $tenantId, string|int $serviceId): ?Service {
|
public function create(string $tenantId, Service $service): array
|
||||||
$serviceId = (string)$serviceId;
|
{
|
||||||
|
$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 === '') {
|
if ($serviceId === '') {
|
||||||
return null;
|
throw new \InvalidArgumentException('Service ID is required for update');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
$document = $service->toStore();
|
||||||
$entry = $this->store->selectCollection($this->serviceCollection)->findOne([
|
$document['modifiedOn'] = new \MongoDB\BSON\UTCDateTime();
|
||||||
|
unset($document['sid'], $document['tid'], $document['createdOn']);
|
||||||
|
|
||||||
|
$this->dataStore->selectCollection(self::COLLECTION_NAME)->updateOne(
|
||||||
|
[
|
||||||
'tid' => $tenantId,
|
'tid' => $tenantId,
|
||||||
'sid' => $serviceId,
|
'sid' => $serviceId,
|
||||||
]);
|
],
|
||||||
|
['$set' => $document]
|
||||||
|
);
|
||||||
|
|
||||||
if ($entry === null) {
|
return $document;
|
||||||
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
|
* Delete a route
|
||||||
*
|
|
||||||
* @param string $tenantId
|
|
||||||
* @param IServiceBase $service
|
|
||||||
*
|
|
||||||
* @return string|int Service ID
|
|
||||||
*/
|
*/
|
||||||
public function createService(string $tenantId, IServiceBase $service): string|int {
|
public function delete(string $tenantId, string|int $serviceId): bool
|
||||||
$id = (string)$service->id();
|
{
|
||||||
if ($id === '') {
|
$result = $this->dataStore->selectCollection(self::COLLECTION_NAME)->deleteOne([
|
||||||
$id = $this->generateServiceId($tenantId);
|
'tid' => $tenantId,
|
||||||
}
|
'sid' => (string)$serviceId,
|
||||||
|
]);
|
||||||
|
|
||||||
$now = date('c');
|
return $result->getDeletedCount() > 0;
|
||||||
$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();
|
|
||||||
}
|
|
||||||
|
|
||||||
$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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+1908
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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
@@ -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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/* System Mail Provider module styles */
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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]'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user