handle('add', 'job', $attr, fn(array $log): bool => $this->response->success(['mailbox_modified', $this->jobs->create($attr)->user2], $log)); } /** POST add/syncjob_source */ public function addSource(array $attr): bool { return $this->handle('add', 'source', $attr, function (array $log) use ($attr): bool { $source = $this->sources->create($attr); $this->tokenFailure($source, $log); return $this->response->success(['imapsync_source_added', $source->name], $log); }); } /** POST edit/syncjob: one message per id; `id` in $attr wins over $items (array_merge like before) */ public function editJobs(?array $items, array $attr): bool { $data = array_merge(['id' => $items], $attr); return $this->handle('edit', 'job', $data, function (array $log) use ($data): bool { $this->jobs->requireAcl(); return $this->batch((array)($data['id'] ?? []), $log, fn(int $id): array => ['mailbox_modified', $this->jobs->update($id, $data)->user2]); }); } /** POST edit/syncjob/run_next (admin): false when no job was moved */ public function runNext(?array $items): bool { return $this->handle('edit', 'run_next', ['id' => $items], function (array $log) use ($items): bool { $outcome = $this->jobs->runNext(array_map('intval', array_values(array_filter((array)$items, 'is_numeric')))); foreach ($outcome['running'] as $id) { $this->response->danger(['imapsync_job_running', $id], $log); } return ($outcome['moved'] > 0) ? $this->response->success(['imapsync_run_next_done', $outcome['moved']], $log) : false; }); } /** POST edit/syncjob_source: one message per id, non-numeric ids are skipped */ public function editSources(?array $items, array $attr): bool { $data = array_merge(['id' => $items], $attr); return $this->handle('edit', 'source', $data, function (array $log) use ($data): bool { $this->sources->requireEditor(); return $this->batch(array_filter((array)($data['id'] ?? []), 'is_numeric'), $log, function (int $id) use ($data, $log): array { $source = $this->sources->update($id, $data); $this->tokenFailure($source, $log); return ['imapsync_source_modified', $source->name]; }); }); } /** POST edit/syncjob_source/refresh_token: the first id only */ public function refreshSourceToken(?array $items): bool { return $this->handle('edit', 'refresh_token', ['id' => $items], function (array $log) use ($items): bool { $source = $this->sources->refreshToken($this->requireId(is_array($items) ? reset($items) : null)); return $this->response->success(['imapsync_source_token_refreshed', $source->name], $log); }); } /** POST edit/imapsync_settings (admin) */ public function editSettings(array $attr): bool { return $this->handle('edit', 'settings', $attr, function (array $log) use ($attr): bool { $this->jobs->saveSettings($attr); return $this->response->success('max_parallel_saved', $log); }); } /** POST delete/syncjob: one message per id (with the id as requested) */ public function deleteJobs(array $items): bool { return $this->handle('delete', 'job', ['id' => $items], function (array $log) use ($items): bool { $this->jobs->requireAcl(); return $this->batch($items, $log, function (int $id, mixed $requested): array { $this->jobs->delete($id); return ['deleted_syncjob', $requested]; }); }); } /** POST delete/syncjob_source: one message per id, non-numeric ids are skipped */ public function deleteSources(array $items): bool { return $this->handle('delete', 'source', ['id' => $items], function (array $log) use ($items): bool { $this->sources->requireAcl(); return $this->batch(array_filter($items, 'is_numeric'), $log, fn(int $id): array => ['imapsync_source_deleted', $this->sources->delete($id)->name]); }); } /** GET syncjob_options */ public function getOptions(): array { return $this->jobs->customParamOptions(); } /** GET imapsync_settings (admin), also admin/system.php */ public function getSettings(): array { try { return $this->jobs->getSettings()->toArray(); } catch (UserError) { return []; } } /** GET syncjob_source/all */ public function getSources(): array { return array_values(array_filter(array_map(fn(int $id): array => $this->getSource($id), $this->sources->visibleIds()))); } /** GET syncjob_source/, also edit.php */ public function getSource(mixed $id): array { $id = $this->toId($id); $source = ($id === null) ? null : $this->sources->get($id); return ($source === null) ? [] : $this->sources->toArray($source); } /** * GET syncjobs/all. Admins get every job (incl. orphaned ones). Others get the jobs of $mailboxes, * the mailboxes the caller manages (json_api collects them with the legacy mailbox() functions * until functions.mailbox.inc.php is converted), in queue order. */ public function getAllJobs(?string $extra, array $mailboxes): array { if ($this->jobs->canListAll()) { return $this->jobRows($this->jobs->allIds(), $extra); } $ids = []; foreach ($mailboxes as $mailbox) { $ids = array_merge($ids, $this->jobs->idsForMailbox((string)$mailbox)); } $rows = $this->jobRows($ids, $extra); // Queue order (C4): prio desc, never-run first, least recently run, id usort($rows, fn(array $a, array $b): int => [$b['prio'], !empty($a['last_run']), (string)$a['last_run'], (int)$a['id']] <=> [$a['prio'], !empty($b['last_run']), (string)$b['last_run'], (int)$b['id']]); return $rows; } /** GET syncjobs/; without a valid mailbox the caller's own jobs */ public function getJobs(?string $mailbox, ?string $extra): array { return $this->jobRows($this->jobs->idsForMailbox($mailbox), $extra); } /** One job (edit.php, inc/ajax/syncjob_logs.php); [] when missing or not accessible. `no_log` in $extra skips the log. */ public function getJob(mixed $id, ?string $extra = null): array { $id = $this->toId($id); $withLog = ($extra === null || !in_array('no_log', explode(',', $extra), true)); $job = ($id === null) ? null : $this->jobs->get($id, $withLog); return ($job === null) ? [] : $job->toArray($withLog); } /** * Account rename ($new) or delete ($new = null) from legacy account code that runs outside a transaction * (admin, domain admin, mailbox delete): a database error becomes a `mysql_error` message. * Inside a transaction (mailbox rename) call $module->sources->onAccountRenamed() directly instead, * so the caller's catch can roll back. */ public function accountChanged(string $old, ?string $new = null): void { try { if ($new === null) { $this->sources->onAccountDeleted($old); } else { $this->sources->onAccountRenamed($old, $new); } } catch (PDOException $e) { $this->response->danger(['mysql_error', $e->getMessage()], ['imapsync_account_changed', $old, $new]); } $this->response->flush(); } /** The token fetched when saving a client_credentials source failed: the source is saved, report the error */ private function tokenFailure(Source $source, array $log): void { if ($source->usesClientCredentials() && $source->active === 1 && $source->lastRefreshError !== null) { $this->response->danger(['imapsync_source_token_refresh_failed', $source->name . ' (' . $source->lastRefreshError . ')'], $log); } } /** * Runs one write call: $work gets the log entry and returns the result for process_*_return(). * A UserError becomes a danger message, a database error stops the call with one `mysql_error` message. */ private function handle(string $action, string $type, mixed $data, callable $work): bool { $log = ['syncjob', $action, $type, $data]; try { $result = $work($log); } catch (UserError $e) { $result = $this->response->error($e, $log); } catch (PDOException $e) { $result = $this->response->danger(['mysql_error', $e->getMessage()], $log); } $this->response->flush(); return $result; } /** One message per id: the success message returned by $work(id, requested id), or the UserError */ private function batch(array $ids, array $log, callable $work): bool { foreach ($ids as $id) { try { $this->response->success($work($this->requireId($id), $id), $log); } catch (UserError $e) { $this->response->error($e, $log); } } return true; } /** @return array[] API rows of the jobs the caller may access */ private function jobRows(array $ids, ?string $extra): array { return array_values(array_filter(array_map(fn(int $id): array => $this->getJob($id, $extra), $ids))); } /** Integer id from request input ("12", 12); null for anything else, also "1.5" (matches no row) */ private function toId(mixed $id): ?int { return (is_numeric($id) && (float)$id == (int)(float)$id) ? (int)(float)$id : null; } /** @throws AccessDenied for ids that cannot exist */ private function requireId(mixed $id): int { return $this->toId($id) ?? throw new AccessDenied(); } }