mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 10:04:18 +00:00
graph works
This commit is contained in:
1 parent
3730cea3af
commit
119f2f03de
14 files changed
+510
-36
No files matched your search
@@ -1508,8 +1508,8 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* }
|
||||
* @psalm-type MercureConfig = array{
|
||||
* hubs?: array<string, array{ // Default: []
|
||||
* url?: scalar|Param|null, // URL of the hub's publish endpoint // Default: null
|
||||
* public_url?: scalar|Param|null, // URL of the hub's public endpoint
|
||||
* url?: scalar|Param|null, // URL of the hub's publish endpoint
|
||||
* public_url?: scalar|Param|null, // URL of the hub's public endpoint // Default: null
|
||||
* jwt?: string|array{ // JSON Web Token configuration.
|
||||
* value?: scalar|Param|null, // JSON Web Token to use to publish to this hub.
|
||||
* provider?: scalar|Param|null, // The ID of a service to call to provide the JSON Web Token.
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260724135613 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE account ALTER imap_host DROP NOT NULL');
|
||||
$this->addSql('ALTER TABLE account ALTER imap_port DROP NOT NULL');
|
||||
$this->addSql('ALTER TABLE account ALTER imap_encryption DROP NOT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE account ALTER imap_host SET NOT NULL');
|
||||
$this->addSql('ALTER TABLE account ALTER imap_port SET NOT NULL');
|
||||
$this->addSql('ALTER TABLE account ALTER imap_encryption SET NOT NULL');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Entity\Account;
|
||||
use App\Repository\AccountRepository;
|
||||
use App\Service\OAuth\OAuthTokenManager;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
/**
|
||||
* Probes a Microsoft account's Graph access endpoint by endpoint, in order of
|
||||
* increasing privilege, so a 403 can be attributed rather than guessed at.
|
||||
*
|
||||
* Deliberately does NOT go through GraphApiClient — the point is to see raw
|
||||
* status codes and Graph error codes without the client's exception mapping
|
||||
* swallowing the detail.
|
||||
*
|
||||
* On token decoding: work/school accounts issue JWT access tokens whose `scp`
|
||||
* claim can be inspected directly. Personal Microsoft accounts do not — they
|
||||
* issue opaque compact tokens (recognisable by the `Ew…` prefix, paired with an
|
||||
* `M.C…_BAY…` refresh token). There is nothing to decode there, which is
|
||||
* exactly why this command probes live endpoints instead.
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'app:graph:diagnose',
|
||||
description: 'Probe Microsoft Graph access for one account and report what works',
|
||||
)]
|
||||
final class GraphDiagnoseCommand extends Command
|
||||
{
|
||||
private const string BASE = 'https://graph.microsoft.com/v1.0';
|
||||
|
||||
public function __construct(
|
||||
private readonly AccountRepository $accountRepository,
|
||||
private readonly OAuthTokenManager $tokenManager,
|
||||
private readonly HttpClientInterface $httpClient,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addArgument('accountId', InputArgument::REQUIRED, 'Local Account id');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$account = $this->accountRepository->find((int) $input->getArgument('accountId'));
|
||||
|
||||
if (null === $account) {
|
||||
$io->error('No such account.');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if (false === $account->isMicrosoft()) {
|
||||
$io->error(sprintf('Account %d is not a Microsoft account.', $account->getId()));
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$io->title(sprintf('Graph diagnosis — #%d %s', $account->getId(), $account->getEmail()));
|
||||
|
||||
$this->reportToken($io, $account);
|
||||
|
||||
try {
|
||||
$token = $this->tokenManager->getValidAccessToken($account);
|
||||
} catch (\Throwable $e) {
|
||||
$io->error('Could not obtain an access token: ' . $e->getMessage());
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// Ordered by increasing privilege: identity, then mailbox existence,
|
||||
// then read, then the optional extras. The first failure localises the
|
||||
// problem.
|
||||
// 'required' marks probes that must pass for sync to work at all.
|
||||
// Master categories are optional: without MailboxSettings.ReadWrite the
|
||||
// category axis degrades, but folders, messages and send are unaffected.
|
||||
$probes = [
|
||||
['identity', 'User.Read — identity', self::BASE . '/me', [], true],
|
||||
['inbox', 'Mail — inbox folder', self::BASE . '/me/mailFolders/inbox', [], true],
|
||||
['folders', 'Mail — folder list (delta)', self::BASE . '/me/mailFolders/delta', [], true],
|
||||
['messages', 'Mail — first message', self::BASE . '/me/messages?$top=1&$select=id', ['Prefer' => 'IdType="ImmutableId"'], true],
|
||||
['categories', 'Categories — master list', self::BASE . '/me/outlook/masterCategories', [], false],
|
||||
];
|
||||
|
||||
$rows = [];
|
||||
$results = [];
|
||||
|
||||
foreach ($probes as [$key, $label, $url, $headers, $required]) {
|
||||
[$status, $detail, $applied] = $this->probe('GET', $url, $token, $headers);
|
||||
|
||||
$ok = $status >= 200 && $status < 300;
|
||||
|
||||
$results[$key] = ['ok' => $ok, 'status' => $status, 'required' => $required];
|
||||
|
||||
$rows[] = [
|
||||
$label . (true === $required ? '' : ' (optional)'),
|
||||
$status,
|
||||
$applied,
|
||||
$detail,
|
||||
];
|
||||
}
|
||||
|
||||
$io->table(['Probe', 'Status', 'Preference-Applied', 'Detail'], $rows);
|
||||
|
||||
$this->interpret($io, $results);
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
// ── Private ───────────────────────────────────────────────────────────────
|
||||
|
||||
private function reportToken(SymfonyStyle $io, Account $account): void
|
||||
{
|
||||
$access = (string) $account->getOauthAccessToken();
|
||||
|
||||
$kind = match (true) {
|
||||
str_starts_with($access, 'eyJ') => 'JWT (work/school account)',
|
||||
str_starts_with($access, 'Ew') => 'opaque compact (personal Microsoft account)',
|
||||
default => 'unrecognised',
|
||||
};
|
||||
|
||||
$io->definitionList(
|
||||
['Token type' => $kind],
|
||||
['Expires' => $account->getOauthTokenExpiry()?->format('Y-m-d H:i:s') ?? '(unset)'],
|
||||
['Immutable ids' => match ($account->getGraphImmutableIds()) {
|
||||
true => 'supported',
|
||||
false => 'NOT supported (dedup falls back to RFC Message-ID)',
|
||||
default => 'not yet probed',
|
||||
}],
|
||||
);
|
||||
|
||||
if ('opaque compact (personal Microsoft account)' === $kind) {
|
||||
$io->note(
|
||||
'Personal accounts issue opaque tokens with no inspectable scope claim, '
|
||||
. 'so granted permissions can only be inferred from the probes below.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,string> $headers
|
||||
* @return array{int, string, string} status, detail, Preference-Applied
|
||||
*/
|
||||
private function probe(string $method, string $url, string $token, array $headers): array
|
||||
{
|
||||
try {
|
||||
$response = $this->httpClient->request($method, $url, [
|
||||
'auth_bearer' => $token,
|
||||
'headers' => $headers,
|
||||
]);
|
||||
|
||||
$status = $response->getStatusCode();
|
||||
$applied = $response->getHeaders(false)['preference-applied'][0] ?? '';
|
||||
|
||||
if ($status >= 200 && $status < 300) {
|
||||
return [$status, 'ok', (string) $applied];
|
||||
}
|
||||
|
||||
$body = json_decode($response->getContent(false), true);
|
||||
$code = $body['error']['code'] ?? 'unknown';
|
||||
$message = $body['error']['message'] ?? '';
|
||||
|
||||
return [$status, sprintf('%s — %s', $code, mb_substr((string) $message, 0, 90)), (string) $applied];
|
||||
} catch (\Throwable $e) {
|
||||
return [0, 'transport: ' . mb_substr($e->getMessage(), 0, 90), ''];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array{ok: bool, status: int, required: bool}> $results
|
||||
*/
|
||||
private function interpret(SymfonyStyle $io, array $results): void
|
||||
{
|
||||
$identityOk = true === ($results['identity']['ok'] ?? false);
|
||||
|
||||
$mailboxOk = true;
|
||||
|
||||
foreach ($results as $result) {
|
||||
if (true === $result['required'] && false === $result['ok']) {
|
||||
$mailboxOk = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (true === $identityOk && false === $mailboxOk) {
|
||||
$io->warning(
|
||||
"Identity works but every mail endpoint fails.\n\n"
|
||||
. "That combination means the token is valid and Graph accepts it — the problem is the\n"
|
||||
. "mailbox, not the credentials. Two causes:\n\n"
|
||||
. " 1. No Outlook mailbox exists behind this Microsoft account. An MSA created from an\n"
|
||||
. " external address (a custom domain hosted elsewhere) has no mailbox provisioned\n"
|
||||
. " unless one was explicitly added. Confirm at outlook.live.com — if you land in a\n"
|
||||
. " setup prompt rather than an inbox, this is it, and no configuration will fix it.\n\n"
|
||||
. " 2. The token predates the Mail.* permissions. Adding scopes in the app registration\n"
|
||||
. " does NOT upgrade already-issued tokens. Delete the account in plMail and\n"
|
||||
. " reconnect so a fresh consent issues a token carrying them."
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (false === $identityOk) {
|
||||
$io->error(
|
||||
'Even /me fails, so the token itself is not being accepted. Check that '
|
||||
. 'OAuthProviderFactory points at https://graph.microsoft.com/ and that the account '
|
||||
. 'was connected after the Graph scopes were configured.'
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (false === $mailboxOk) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Core sync works. Report optional gaps separately so they are not
|
||||
// mistaken for a broken account.
|
||||
if (false === ($results['categories']['ok'] ?? false)) {
|
||||
$io->warning(
|
||||
"Mail sync is healthy, but master categories are not accessible.\n\n"
|
||||
. "/me/outlook/masterCategories is NOT covered by Mail.ReadWrite — it lives under the\n"
|
||||
. "Outlook user-settings resource and needs MailboxSettings.ReadWrite.\n\n"
|
||||
. "Effect while missing: folders, messages, attachments and send all work. Categories\n"
|
||||
. "do not sync in either direction, so labels that are not folder-backed will not\n"
|
||||
. "appear in Outlook and Outlook categories will not appear in plMail.\n\n"
|
||||
. "Fix: add MailboxSettings.ReadWrite (delegated) in the app registration, then\n"
|
||||
. "delete and reconnect the account — adding a scope does not upgrade issued tokens."
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$io->success('All probes passed — Graph access is healthy for this account.');
|
||||
}
|
||||
}
|
||||
@@ -132,17 +132,26 @@ class OAuthController extends AbstractController
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
|
||||
// Email alone does NOT identify an account: an OAuth provider's login
|
||||
// address is independent of where the mail is hosted, so the same
|
||||
// address can legitimately exist as both an IMAP account and an OAuth
|
||||
// one. Adopting by email would silently convert the IMAP account and
|
||||
// null its password. Match on the full identity instead.
|
||||
$account = $this->accountRepository->findOneBy([
|
||||
'usr' => $user,
|
||||
'email' => $email,
|
||||
'usr' => $user,
|
||||
'email' => $email,
|
||||
'authType' => AuthType::OAuth2->value,
|
||||
'oauthProvider' => $provider->value,
|
||||
]);
|
||||
|
||||
if (null === $account) {
|
||||
$account = new Account();
|
||||
$account->setUsr($user);
|
||||
$account->setEmail($email);
|
||||
$account->setName($email);
|
||||
$account->setIsActive(true);
|
||||
$duplicate = $this->accountRepository->count(['usr' => $user, 'email' => $email]) > 0;
|
||||
|
||||
$account = new Account()
|
||||
->setUsr($user)
|
||||
->setEmail($email)
|
||||
->setName($duplicate ? sprintf('%s (%s)', $email, ucfirst($provider->value)) : $email)
|
||||
->setIsActive(true);
|
||||
}
|
||||
|
||||
$account->setUsername($email);
|
||||
|
||||
@@ -118,7 +118,10 @@ final class AccountPushController extends AbstractController
|
||||
$status = $status->withFailure();
|
||||
}
|
||||
|
||||
return $this->render('settings/accounts/_push_toggle.html.twig', [
|
||||
// The compact row control, since both actions are triggered from the
|
||||
// accounts list. If the roomier card in the edit pane ever posts here
|
||||
// too, pass the template name in rather than branching on the referer.
|
||||
return $this->render('settings/accounts/_push_control.html.twig', [
|
||||
'account' => $account,
|
||||
'status' => $status,
|
||||
]);
|
||||
|
||||
@@ -45,6 +45,12 @@ enum MailProvider: string
|
||||
'https://graph.microsoft.com/User.Read',
|
||||
'https://graph.microsoft.com/Mail.ReadWrite',
|
||||
'https://graph.microsoft.com/Mail.Send',
|
||||
// Master categories live under the Outlook user-settings
|
||||
// resource, NOT under Mail.* — /me/outlook/masterCategories
|
||||
// returns ErrorAccessDenied without this. ReadWrite rather than
|
||||
// Read because GraphCategorySyncer and ApplyGraphChangesHandler
|
||||
// both create category definitions.
|
||||
'https://graph.microsoft.com/MailboxSettings.ReadWrite',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -31,8 +31,17 @@ class ImapConnectionFactory
|
||||
default => false,
|
||||
};
|
||||
|
||||
$host = $account->getImapHost();
|
||||
|
||||
if (null === $host || '' === $host) {
|
||||
throw new \RuntimeException(sprintf(
|
||||
'Account %d (%s) has no IMAP host — it is API-synced and must not be opened over IMAP.',
|
||||
$account->getId(),
|
||||
$account->getEmail(),
|
||||
));
|
||||
}
|
||||
$accountConfig = [
|
||||
'host' => $account->getImapHost(),
|
||||
'host' => $host,
|
||||
'port' => $account->getImapPort(),
|
||||
'encryption' => $encryption,
|
||||
'validate_cert' => true,
|
||||
|
||||
@@ -37,13 +37,13 @@ class Account extends AccountModel
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $email = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $imapHost = null;
|
||||
|
||||
#[ORM\Column]
|
||||
#[ORM\Column(nullable: true)]
|
||||
private ?int $imapPort = null;
|
||||
|
||||
#[ORM\Column(length: 20)]
|
||||
#[ORM\Column(length: 20, nullable: true)]
|
||||
private ?string $imapEncryption = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
@@ -214,7 +214,7 @@ class Account extends AccountModel
|
||||
return $this->imapHost;
|
||||
}
|
||||
|
||||
public function setImapHost(string $imapHost): static
|
||||
public function setImapHost(?string $imapHost): static
|
||||
{
|
||||
$this->imapHost = $imapHost;
|
||||
|
||||
@@ -226,7 +226,7 @@ class Account extends AccountModel
|
||||
return $this->imapPort;
|
||||
}
|
||||
|
||||
public function setImapPort(int $imapPort): static
|
||||
public function setImapPort(?int $imapPort): static
|
||||
{
|
||||
$this->imapPort = $imapPort;
|
||||
|
||||
@@ -238,7 +238,7 @@ class Account extends AccountModel
|
||||
return $this->imapEncryption;
|
||||
}
|
||||
|
||||
public function setImapEncryption(string $imapEncryption): static
|
||||
public function setImapEncryption(?string $imapEncryption): static
|
||||
{
|
||||
$this->imapEncryption = $imapEncryption;
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Service\Graph;
|
||||
use App\Entity\Account;
|
||||
use App\Service\Label\LabelResolver;
|
||||
use App\Service\Mail\GraphApiClient;
|
||||
use App\Service\Mail\GraphApiException;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
@@ -39,6 +40,28 @@ final readonly class GraphCategorySyncer
|
||||
{
|
||||
try {
|
||||
$categories = $this->apiClient->listMasterCategories($account);
|
||||
} catch (GraphApiException $e) {
|
||||
// 403 here almost always means the MailboxSettings.ReadWrite scope
|
||||
// is missing rather than anything being broken: master categories
|
||||
// live under the Outlook user-settings resource, not under Mail.*.
|
||||
// Degrade quietly — folders, messages and send are unaffected, only
|
||||
// the category axis is unavailable.
|
||||
if (403 === $e->getStatus()) {
|
||||
$this->logger->warning(
|
||||
'GraphCategorySyncer: no access to master categories — '
|
||||
. 'the account likely needs reconnecting with MailboxSettings.ReadWrite',
|
||||
['accountId' => $account->getId()],
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->logger->error('GraphCategorySyncer: listing failed', [
|
||||
'accountId' => $account->getId(),
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return;
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('GraphCategorySyncer: listing failed', [
|
||||
'accountId' => $account->getId(),
|
||||
|
||||
@@ -29,13 +29,42 @@ use Psr\Log\LoggerInterface;
|
||||
* reliable: a folder literally named "A/B" would break the Gmail approach but
|
||||
* is handled correctly here.
|
||||
*
|
||||
* Immutable IDs do NOT cover mailFolder resources, so folder ids can change.
|
||||
* System folders are therefore always re-resolved by wellKnownName rather than
|
||||
* trusting a stored id, and a custom folder whose id has rotated is re-linked
|
||||
* by its name chain on the next run.
|
||||
* ── Why roles are resolved by a separate lookup ───────────────────────────
|
||||
* The obvious implementation reads a `wellKnownName` property off each folder
|
||||
* in the list. The v1.0 endpoint refuses to project that property onto
|
||||
* microsoft.graph.mailFolder and fails the whole request with
|
||||
* "Could not find a property named 'wellKnownName'". So instead each well-known
|
||||
* name is resolved to an id up front — Graph accepts a well-known name in place
|
||||
* of an id on the mailFolders path — and the resulting id => name map is
|
||||
* consulted while walking the list.
|
||||
*
|
||||
* That is also the more correct approach: immutable ids do not cover mailFolder
|
||||
* resources, so system folder ids can rotate, and resolving by name every sync
|
||||
* means a rotated id re-links itself.
|
||||
*/
|
||||
final readonly class GraphFolderSyncer
|
||||
{
|
||||
/**
|
||||
* Well-known names we resolve to ids. Ones the mailbox does not have are
|
||||
* skipped silently by the client — `archive` is missing on some mailboxes,
|
||||
* and the recoverable-items folders are absent on consumer accounts.
|
||||
*/
|
||||
private const array WELL_KNOWN_NAMES = [
|
||||
'msgfolderroot',
|
||||
'inbox',
|
||||
'sentitems',
|
||||
'drafts',
|
||||
'deleteditems',
|
||||
'junkemail',
|
||||
'archive',
|
||||
'outbox',
|
||||
'searchfolders',
|
||||
'conversationhistory',
|
||||
'recoverableitemsdeletions',
|
||||
'serverfailures',
|
||||
'syncissues',
|
||||
];
|
||||
|
||||
/**
|
||||
* Folders we deliberately do not model as labels — Exchange plumbing that
|
||||
* has no user-facing meaning.
|
||||
@@ -75,6 +104,12 @@ final readonly class GraphFolderSyncer
|
||||
{
|
||||
$folders = $this->apiClient->listFolders($account);
|
||||
|
||||
// wellKnownName => folderId, then inverted so the walk can ask
|
||||
// "what role, if any, does this folder id have?"
|
||||
$wellKnownByName = $this->apiClient->resolveWellKnownFolders($account, self::WELL_KNOWN_NAMES);
|
||||
$nameByFolderId = array_flip($wellKnownByName);
|
||||
$rootId = $wellKnownByName['msgfolderroot'] ?? '';
|
||||
|
||||
/** @var array<string, array<string,mixed>> $byId */
|
||||
$byId = [];
|
||||
|
||||
@@ -92,7 +127,7 @@ final readonly class GraphFolderSyncer
|
||||
$synced = 0;
|
||||
|
||||
foreach ($byId as $id => $folder) {
|
||||
$wellKnown = strtolower((string) ($folder['wellKnownName'] ?? ''));
|
||||
$wellKnown = $nameByFolderId[$id] ?? '';
|
||||
|
||||
if (true === in_array($wellKnown, self::IGNORED_WELL_KNOWN, true)) {
|
||||
continue;
|
||||
@@ -103,7 +138,7 @@ final readonly class GraphFolderSyncer
|
||||
if (true === array_key_exists($wellKnown, self::SYSTEM_MAP)) {
|
||||
$label = $this->labelResolver->systemLabel(self::SYSTEM_MAP[$wellKnown], $account);
|
||||
} else {
|
||||
$segments = $this->nameChain($id, $byId);
|
||||
$segments = $this->nameChain($id, $byId, $rootId);
|
||||
|
||||
if (count($segments) === 0) {
|
||||
continue;
|
||||
@@ -157,9 +192,10 @@ final readonly class GraphFolderSyncer
|
||||
* Walk parentFolderId upward, collecting display names root-first.
|
||||
*
|
||||
* @param array<string, array<string,mixed>> $byId
|
||||
* @param string $rootId msgfolderroot id, walk stops there
|
||||
* @return list<string>
|
||||
*/
|
||||
private function nameChain(string $folderId, array $byId): array
|
||||
private function nameChain(string $folderId, array $byId, string $rootId): array
|
||||
{
|
||||
$segments = [];
|
||||
$cursor = $folderId;
|
||||
@@ -173,14 +209,12 @@ final readonly class GraphFolderSyncer
|
||||
break;
|
||||
}
|
||||
|
||||
$folder = $byId[$cursor];
|
||||
$wellKnown = strtolower((string) ($folder['wellKnownName'] ?? ''));
|
||||
|
||||
if ('msgfolderroot' === $wellKnown) {
|
||||
if ('' !== $rootId && $cursor === $rootId) {
|
||||
break;
|
||||
}
|
||||
|
||||
$name = trim((string) ($folder['displayName'] ?? ''));
|
||||
$folder = $byId[$cursor];
|
||||
$name = trim((string) ($folder['displayName'] ?? ''));
|
||||
|
||||
if ('' !== $name) {
|
||||
array_unshift($segments, $name);
|
||||
|
||||
@@ -4,6 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Mail;
|
||||
|
||||
use App\Domain\Exception\GraphApiException;
|
||||
use App\Domain\Exception\GraphResyncRequiredException;
|
||||
use App\Domain\Exception\GraphThrottledException;
|
||||
use App\Entity\Account;
|
||||
use App\Service\OAuth\OAuthTokenManager;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
@@ -64,6 +67,14 @@ final class GraphApiClient
|
||||
/**
|
||||
* Every mail folder in the mailbox, flattened, following pagination.
|
||||
*
|
||||
* Uses the delta endpoint because plain /me/mailFolders returns only
|
||||
* top-level folders — delta returns the whole tree in one flattened pass.
|
||||
*
|
||||
* Deliberately does NOT $select wellKnownName: the v1.0 endpoint refuses
|
||||
* it on microsoft.graph.mailFolder ("Could not find a property named
|
||||
* 'wellKnownName'"), so roles are resolved by name instead — see
|
||||
* resolveWellKnownFolders().
|
||||
*
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
public function listFolders(Account $account): array
|
||||
@@ -71,21 +82,58 @@ final class GraphApiClient
|
||||
return $this->collectPages(
|
||||
$account,
|
||||
self::ME . '/mailFolders/delta',
|
||||
['$select' => 'id,displayName,parentFolderId,wellKnownName,totalItemCount'],
|
||||
['$select' => 'id,displayName,parentFolderId,childFolderCount,totalItemCount'],
|
||||
)['items'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a well-known folder (inbox, sentitems, drafts, deleteditems,
|
||||
* junkemail, archive) to its current id. Folder ids are not immutable, so
|
||||
* this is called rather than cached across syncs.
|
||||
* Map each well-known folder name to its current folder id.
|
||||
*
|
||||
* Graph accepts a well-known name in place of an id on the mailFolders
|
||||
* path, which is the supported way to identify system folders. It is also
|
||||
* the only reliable way: immutable ids do not cover mailFolder resources,
|
||||
* so a stored system-folder id can rotate, and wellKnownName cannot be
|
||||
* projected onto the folder list in v1.0.
|
||||
*
|
||||
* Names that do not exist on the mailbox are skipped — `archive` is absent
|
||||
* on some mailboxes, and the recoverable-items folders are absent on
|
||||
* consumer accounts.
|
||||
*
|
||||
* @param list<string> $names
|
||||
* @return array<string, string> wellKnownName => folderId
|
||||
*/
|
||||
public function resolveWellKnownFolders(Account $account, array $names): array
|
||||
{
|
||||
$map = [];
|
||||
|
||||
foreach ($names as $name) {
|
||||
try {
|
||||
$folder = $this->getWellKnownFolder($account, $name);
|
||||
} catch (GraphApiException) {
|
||||
// 404 for a folder this mailbox does not have. Not an error.
|
||||
continue;
|
||||
}
|
||||
|
||||
$id = (string) ($folder['id'] ?? '');
|
||||
|
||||
if ('' !== $id) {
|
||||
$map[$name] = $id;
|
||||
}
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a single well-known folder (inbox, sentitems, drafts,
|
||||
* deleteditems, junkemail, archive, msgfolderroot) to its current id.
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function getWellKnownFolder(Account $account, string $wellKnownName): array
|
||||
{
|
||||
return $this->request($account, 'GET', self::ME . '/mailFolders/' . rawurlencode($wellKnownName), [
|
||||
'query' => ['$select' => 'id,displayName,parentFolderId,wellKnownName'],
|
||||
'query' => ['$select' => 'id,displayName,parentFolderId'],
|
||||
])->toArray();
|
||||
}
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if account.microsoft or account.gmail %}
|
||||
{{ include('settings/accounts/_push_toggle.html.twig', {account: account, failed: false}) }}
|
||||
{{ include('settings/accounts/_push_control.html.twig', {account: account, failed: false}) }}
|
||||
{% endif %}
|
||||
<form method="post" action="{{ path('app_account_toggle', { id: account.id }) }}">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('toggle' ~ account.id) }}">
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
{# Compact push control for a single account row in the accounts settings list.
|
||||
|
||||
Include with just the account; it resolves its own state:
|
||||
|
||||
{{ include('settings/accounts/_push_control.html.twig', {account: account}) }}
|
||||
|
||||
Renders nothing for providers without push (IMAP), so no conditional is
|
||||
needed at the call site — the row simply has one fewer control.
|
||||
|
||||
For the roomier card version (headings, hint text, labelled repair button)
|
||||
use _push_toggle.html.twig in the account edit pane instead.
|
||||
|
||||
NOTE: `class="contents"` on the turbo-frame is load-bearing. <turbo-frame>
|
||||
is display:block by default, which breaks it out of the row's flex layout
|
||||
and pushes every sibling control out of alignment. #}
|
||||
|
||||
{% set status = status|default(push_status(account)) %}
|
||||
|
||||
<turbo-frame id="account-push-{{ account.id }}" class="contents">
|
||||
{% if status.supported %}
|
||||
{% set degraded = status.health.value == 'degraded' %}
|
||||
|
||||
{# When push is registered but not delivering, the useful action is to
|
||||
re-register, not to toggle off. Same control, different target. #}
|
||||
{% set action = degraded
|
||||
? path('settings_account_push_repair', {id: account.id})
|
||||
: path('settings_account_push_toggle', {id: account.id}) %}
|
||||
|
||||
{% if status.failed %}
|
||||
{% set tooltip = 'settings.accounts.push.failed'|trans %}
|
||||
{% elseif degraded %}
|
||||
{% set tooltip = 'settings.accounts.push.degraded_hint'|trans %}
|
||||
{% elseif not status.configured and not status.enabled %}
|
||||
{% set tooltip = 'settings.accounts.push.not_configured'|trans %}
|
||||
{% elseif status.enabled %}
|
||||
{% set tooltip = 'settings.accounts.push.on_hint'|trans %}
|
||||
{% else %}
|
||||
{% set tooltip = 'settings.accounts.push.off_hint'|trans %}
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="{{ action }}" class="contents">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('account_push_' ~ account.id) }}">
|
||||
|
||||
<button type="submit"
|
||||
title="{{ tooltip }}"
|
||||
aria-label="{{ 'settings.accounts.push.title'|trans }}: {{ status.health.translationKey|trans }}"
|
||||
{{ not status.isToggleable and not degraded ? 'disabled' : '' }}
|
||||
class="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-sm transition-colors
|
||||
focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500
|
||||
disabled:cursor-not-allowed disabled:opacity-40
|
||||
{% if status.failed or degraded %}
|
||||
text-amber-600 hover:bg-amber-100/60 dark:text-amber-400 dark:hover:bg-amber-950/40
|
||||
{% elseif status.enabled %}
|
||||
text-emerald-600 hover:bg-emerald-100/60 dark:text-emerald-400 dark:hover:bg-emerald-950/40
|
||||
{% else %}
|
||||
text-zinc-400 hover:bg-zinc-100/60 dark:text-zinc-500 dark:hover:bg-zinc-800/60
|
||||
{% endif %}">
|
||||
<i class="fa-solid {{ status.failed ? 'fa-triangle-exclamation' : status.health.icon }}"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</turbo-frame>
|
||||
@@ -15,7 +15,7 @@
|
||||
{% set status = status|default(push_status(account)) %}
|
||||
|
||||
{% if status.supported %}
|
||||
<turbo-frame id="account-push-{{ account.id }}">
|
||||
<turbo-frame id="account-push-{{ account.id }}" class="block">
|
||||
<div class="rounded-xl border border-white/40 dark:border-white/10 bg-white/80 dark:bg-gray-900/80 backdrop-blur-xl p-4">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
|
||||
Reference in new issue
Block a user