feat: use mail manager and mail providers

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-07-19 07:08:48 -04:00
parent d8f0b4073c
commit fc3f4c28db
19 changed files with 3622 additions and 2171 deletions
+5 -9
View File
@@ -8,11 +8,6 @@ use KTXF\Module\ModuleInstanceAbstract;
use KTXF\Resource\Provider\ProviderInterface;
use KTXM\ProviderMailSystem\Providers\Provider;
/**
* SMTP Mail Provider Module
*
* Provides outbound-only mail service via SMTP using Symfony Mailer.
*/
class Module extends ModuleInstanceAbstract implements ModuleBrowserInterface
{
public function __construct(
@@ -36,12 +31,12 @@ class Module extends ModuleInstanceAbstract implements ModuleBrowserInterface
public function description(): string
{
return 'System mail provider module for Ktrix - provides outbound mail delivery via SMTP';
return 'System mail provider module for Ktrix - routes system-generated mail to configured mail services';
}
public function version(): string
{
return '0.0.1';
return '0.0.2';
}
public function permissions(): array
@@ -54,7 +49,7 @@ class Module extends ModuleInstanceAbstract implements ModuleBrowserInterface
],
];
}
public function boot(): void
{
$this->providerManager->register(ProviderInterface::TYPE_MAIL, 'system', Provider::class);
@@ -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',
];
}
}
+226 -200
View File
@@ -9,228 +9,254 @@ declare(strict_types=1);
namespace KTXM\ProviderMailSystem\Providers;
use InvalidArgumentException;
use KTXC\Resource\ProviderManager;
use KTXF\Mail\Provider\ProviderBaseInterface;
use KTXF\Mail\Selector\ServiceSelector;
use KTXF\Mail\Service\IServiceBase;
use KTXF\Mail\Service\ServiceScope;
use KTXF\Mail\Provider\ProviderServiceMutateInterface;
use KTXF\Resource\Provider\ResourceServiceMutateInterface;
use KTXM\ProviderMailSystem\Stores\ServiceStore;
use Psr\Log\LoggerInterface;
/**
* SMTP Mail Provider
*
* Provider for SMTP-based mail services. Supports multiple configured
* services per tenant with system and user scopes.
*
* @since 2025.05.01
* System Mail Provider
*
* 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).
*
* 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 = [
self::CAPABILITY_SERVICE_LIST => true,
self::CAPABILITY_SERVICE_FETCH => true,
self::CAPABILITY_SERVICE_EXTANT => true,
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()) {
return false;
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 ($selector->getOwner() !== null && $service->getOwner() !== $selector->getOwner()) {
return false;
if ($service->hasAddressExact($address)) {
return $service;
}
if ($selector->getAddress() !== null && !$service->handlesAddress($selector->getAddress())) {
return false;
if ($catchAll === null && $service->hasAddressPattern($address)) {
$catchAll = $service;
}
if ($selector->getCapabilities() !== null) {
foreach ($selector->getCapabilities() as $cap) {
if (!$service->capable($cap)) {
return false;
}
}
}
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;
}
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;
}
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');
}
}
}
+462 -599
View File
File diff suppressed because it is too large Load Diff
+109 -313
View File
@@ -9,354 +9,150 @@ declare(strict_types=1);
namespace KTXM\ProviderMailSystem\Stores;
use KTXF\Mail\Entity\Address;
use KTXF\Mail\Service\IServiceBase;
use KTXF\Mail\Service\ServiceIdentityBasic;
use KTXF\Mail\Service\ServiceLocation;
use KTXF\Mail\Service\ServiceScope;
use KTXM\ProviderMailSystem\Providers\Service;
use KTXC\Db\DataStore;
use Psr\Log\LoggerInterface;
use KTXF\Utile\UUID;
use KTXM\ProviderMailSystem\Providers\Service;
/**
* Service Store
*
* @since 2025.05.01
* System Mail Route Store
*
* 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 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
*
* @param string $tenantId
*
* @return array<string|int, Service>
* Check existence of routes by IDs for a tenant
*/
public function listServices(string $tenantId): array {
try {
$cursor = $this->store->selectCollection($this->serviceCollection)->find([
'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(),
]);
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;
}
/**
* 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
* Retrieve a single route by ID
*/
public function findServiceByAddress(string $tenantId, string $address): ?Service {
$address = strtolower(trim($address));
if ($address === '') {
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;
}
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;
}
return $document;
}
/**
* Get a specific service
*
* @param string $tenantId
* @param string|int $serviceId
*
* @return Service|null
* Create a new route
*/
public function getService(string $tenantId, string|int $serviceId): ?Service {
$serviceId = (string)$serviceId;
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 === '') {
return null;
throw new \InvalidArgumentException('Service ID is required for update');
}
try {
$entry = $this->store->selectCollection($this->serviceCollection)->findOne([
$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]
);
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;
}
return $document;
}
/**
* Create a new service
*
* @param string $tenantId
* @param IServiceBase $service
*
* @return string|int Service ID
* Delete a route
*/
public function createService(string $tenantId, IServiceBase $service): string|int {
$id = (string)$service->id();
if ($id === '') {
$id = $this->generateServiceId($tenantId);
}
public function delete(string $tenantId, string|int $serviceId): bool
{
$result = $this->dataStore->selectCollection(self::COLLECTION_NAME)->deleteOne([
'tid' => $tenantId,
'sid' => (string)$serviceId,
]);
$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();
}
$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;
return $result->getDeletedCount() > 0;
}
}