Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,11 @@ Learn more about the Nextcloud Ethical AI Rating [in our blog](https://nextcloud
<command>OCA\Mail\Command\InspectMailbox</command>
<command>OCA\Mail\Command\ListMailboxes</command>
<command>OCA\Mail\Command\PredictImportance</command>
<command>OCA\Mail\Command\CreateProvisioning</command>
<command>OCA\Mail\Command\DeleteProvisioning</command>
<command>OCA\Mail\Command\ListProvisionings</command>
<command>OCA\Mail\Command\ProvisionAccounts</command>
<command>OCA\Mail\Command\UpdateProvisioning</command>
<command>OCA\Mail\Command\TestAccount</command>
<command>OCA\Mail\Command\SyncAccount</command>
<command>OCA\Mail\Command\Thread</command>
Expand Down
73 changes: 73 additions & 0 deletions lib/Command/CreateProvisioning.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<?php

declare(strict_types=1);

/*
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Mail\Command;

use InvalidArgumentException;
use OCA\Mail\Exception\ValidationException;
use OCA\Mail\Service\Provisioning\Manager as ProvisioningManager;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

final class CreateProvisioning extends Command {
use ProvisioningOptions;

public function __construct(
private readonly ProvisioningManager $provisioningManager,
) {
parent::__construct();
}

protected function configure(): void {
$this->setName('mail:provisioning:create');
$this->setDescription('Create a mail account provisioning configuration');
$this->setHelp(sprintf(
<<<'EOT'
A provisioning configuration creates and maintains the mail account of every
user whose email address matches its domain. Pass * as the domain to match all
users.

All options are required, except the Sieve, master password and LDAP alias ones.

%s

New accounts are provisioned when a user opens Mail. Run
<info>mail:provisioning:apply</info> to provision all users right away.
EOT,
$this->templatesHelp(),
));
$this->addUsage(
"--provisioning-domain='*' --email-template='%USERID%@example.com'"
. " --imap-user='%USERID%' --imap-host=imap.example.com --imap-port=993 --imap-ssl-mode=ssl"
. " --smtp-user='%USERID%' --smtp-host=smtp.example.com --smtp-port=587 --smtp-ssl-mode=tls"
);
$this->addProvisioningOptions();
}

protected function execute(InputInterface $input, OutputInterface $output): int {
try {
$data = $this->buildProvisioningData($input, $output);
} catch (InvalidArgumentException $e) {
$output->writeln('<error>' . $e->getMessage() . '</error>');
return self::INVALID;
}

try {
$provisioning = $this->provisioningManager->newProvisioning($data);
} catch (ValidationException $e) {
$output->writeln('<error>Invalid or missing values: ' . implode(', ', array_keys($e->getFields())) . '</error>');
return self::INVALID;
}

$output->writeln("<info>Provisioning configuration {$provisioning->getId()} created</info>");

return self::SUCCESS;
}
}
77 changes: 77 additions & 0 deletions lib/Command/DeleteProvisioning.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<?php

declare(strict_types=1);

/*
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Mail\Command;

use OCA\Mail\Service\Provisioning\Manager as ProvisioningManager;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ConfirmationQuestion;

final class DeleteProvisioning extends Command {
public const ARGUMENT_ID = 'id';
public const OPTION_FORCE = 'force';

public function __construct(
private readonly ProvisioningManager $provisioningManager,
) {
parent::__construct();
}

protected function configure(): void {
$this->setName('mail:provisioning:delete');
$this->setDescription('Delete a mail account provisioning configuration and all accounts provisioned with it');
$this->setHelp(
<<<'EOT'
Deleting a configuration also deletes every mail account that was provisioned
with it, including the locally cached messages. Mail accounts users created
themselves are not affected.

Run <info>mail:provisioning:list</info> to look up the id of a configuration.
EOT
);
$this->addUsage('42 --force');
$this->addArgument(self::ARGUMENT_ID, InputArgument::REQUIRED, 'Id of the provisioning configuration');
$this->addOption(self::OPTION_FORCE, 'f', InputOption::VALUE_NONE, 'Delete without asking for confirmation');
}

protected function execute(InputInterface $input, OutputInterface $output): int {
$id = filter_var($input->getArgument(self::ARGUMENT_ID), FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
if ($id === false) {
$output->writeln('<error>Provisioning configuration id must be a positive integer</error>');
return self::INVALID;
}

$provisioning = $this->provisioningManager->getConfigById($id);
if ($provisioning === null) {
$output->writeln("<error>Provisioning configuration $id does not exist</error>");
return self::FAILURE;
}

if (!$input->getOption(self::OPTION_FORCE)) {
$question = new ConfirmationQuestion(
"Delete configuration $id and all mail accounts provisioned with it? [y/N] ",
false,
);
if (!$this->getHelper('question')->ask($input, $output, $question)) {
$output->writeln('Aborted');
return self::SUCCESS;
}
}

$this->provisioningManager->deprovision($provisioning);

$output->writeln("<info>Provisioning configuration $id deleted</info>");

return self::SUCCESS;
}
}
67 changes: 67 additions & 0 deletions lib/Command/ListProvisionings.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<?php

declare(strict_types=1);

/*
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Mail\Command;

use OCA\Mail\Service\Provisioning\Manager as ProvisioningManager;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;

final class ListProvisionings extends Command {
public const OPTION_JSON = 'json';

public function __construct(
private readonly ProvisioningManager $provisioningManager,
) {
parent::__construct();
}

protected function configure(): void {
$this->setName('mail:provisioning:list');
$this->setDescription('List the mail account provisioning configurations');
$this->setHelp(
<<<'EOT'
The table shows a summary of every configuration and the ids needed by
<info>mail:provisioning:update</info> and <info>mail:provisioning:delete</info>.
Pass <info>--json</info> to print all values. The master password is never printed.
EOT
);
$this->addOption(self::OPTION_JSON, null, InputOption::VALUE_NONE, 'Print all values as JSON');
}

protected function execute(InputInterface $input, OutputInterface $output): int {
$provisionings = $this->provisioningManager->getConfigs();

if ($input->getOption(self::OPTION_JSON)) {
$output->writeln(json_encode($provisionings, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR));

return self::SUCCESS;
}

$table = new Table($output);
$table->setHeaders(['Id', 'Domain', 'Email', 'IMAP', 'SMTP', 'Sieve', 'Master password']);
foreach ($provisionings as $provisioning) {
$table->addRow([
$provisioning->getId(),
$provisioning->getProvisioningDomain(),
$provisioning->getEmailTemplate(),
$provisioning->getImapUser() . '@' . $provisioning->getImapHost() . ':' . $provisioning->getImapPort(),
$provisioning->getSmtpUser() . '@' . $provisioning->getSmtpHost() . ':' . $provisioning->getSmtpPort(),
$provisioning->getSieveEnabled() === true ? $provisioning->getSieveHost() . ':' . $provisioning->getSievePort() : 'no',
$provisioning->getMasterPasswordEnabled() === true ? 'yes' : 'no',
]);
}
$table->render();

return self::SUCCESS;
}
}
50 changes: 50 additions & 0 deletions lib/Command/ProvisionAccounts.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

declare(strict_types=1);

/*
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Mail\Command;

use OCA\Mail\Service\Provisioning\Manager as ProvisioningManager;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

final class ProvisionAccounts extends Command {
public function __construct(
private readonly ProvisioningManager $provisioningManager,
) {
parent::__construct();
}

protected function configure(): void {
$this->setName('mail:provisioning:apply');
$this->setDescription('Apply the provisioning configurations to all users');
$this->setHelp(
<<<'EOT'
Walks through all users and creates or updates the mail account of everyone
matching a provisioning configuration, instead of waiting for each user to open
Mail. Users who lost access to Mail lose their provisioned account. Existing
accounts are kept when their owner no longer matches any configuration.

This takes no options. Configurations are managed with
<info>mail:provisioning:create</info> and <info>mail:provisioning:update</info>.

The IMAP, SMTP and Sieve password can only be stored while the user is logged
in, so provisioned accounts start syncing once their owner opens Mail.
EOT
);
}

protected function execute(InputInterface $input, OutputInterface $output): int {
$count = $this->provisioningManager->provision();

$output->writeln("<info>Provisioned $count accounts</info>");

return self::SUCCESS;
}
}
Loading
Loading