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"
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.2 <=8.5",
|
||||
"symfony/mailer": "^7.0"
|
||||
"php": ">=8.2 <=8.5"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
||||
Generated
+5
-1048
File diff suppressed because it is too large
Load Diff
+4
-8
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+125
-329
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
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