' . Ulid::PATTERN . ')'; /** * Settings schemas already read this request, keyed by Connection. * * @var array|\Throwable> */ private array $schemas = []; public function __construct( private readonly DestinationRegistry $registry, private readonly DestinationStore $destinations, private readonly ConnectionStore $connections, private readonly HealthStore $health, private readonly DeliveryFailures $failures, private readonly BulkRePush $rePush, private readonly PushDispatcher $dispatcher, private readonly ?DestinationUsage $usage = null, ) { } public function registerRoutes(): void { register_rest_route(Routes::NAMESPACE, '/connections', [ ['methods' => 'POST', 'callback' => [$this, 'saveConnection'], 'permission_callback' => [Routes::class, 'canManage']], ]); register_rest_route(Routes::NAMESPACE, '/connections/' . self::ID_PATTERN, [ ['methods' => 'DELETE', 'callback' => [$this, 'deleteConnection'], 'permission_callback' => [Routes::class, 'canManage']], ]); register_rest_route(Routes::NAMESPACE, '/connections/' . self::ID_PATTERN . '/regenerate', [ ['methods' => 'POST', 'callback' => [$this, 'regenerateConnection'], 'permission_callback' => [Routes::class, 'canManage']], ]); register_rest_route(Routes::NAMESPACE, '/connections/' . self::ID_PATTERN . '/check', [ ['methods' => 'POST', 'callback' => [$this, 'checkConnection'], 'permission_callback' => [Routes::class, 'canManage']], ]); register_rest_route(Routes::NAMESPACE, '/destinations/schema', [ ['methods' => 'GET', 'callback' => [$this, 'selectedSchema'], 'permission_callback' => [Routes::class, 'canManage']], ]); register_rest_route(Routes::NAMESPACE, '/destinations', [ [ 'methods' => 'GET', 'callback' => [$this, 'index'], 'permission_callback' => [Routes::class, 'canManage'], ], [ 'methods' => 'POST', 'callback' => [$this, 'store'], 'permission_callback' => [Routes::class, 'canManage'], 'args' => [ 'id' => ['type' => 'string', 'pattern' => '^' . Ulid::PATTERN . '$'], 'type' => ['type' => 'string', 'required' => true], 'label' => ['type' => 'string'], 'connection' => ['type' => ['string', 'null']], 'settings' => ['type' => 'object'], ], ], ]); register_rest_route(Routes::NAMESPACE, '/destinations/' . self::ID_PATTERN, [ [ 'methods' => 'DELETE', 'callback' => [$this, 'destroy'], 'permission_callback' => [Routes::class, 'canManage'], ], ]); register_rest_route(Routes::NAMESPACE, '/destinations/' . self::ID_PATTERN . '/repush', [ [ 'methods' => 'POST', 'callback' => [$this, 'repush'], 'permission_callback' => [Routes::class, 'canManage'], ], ]); register_rest_route(Routes::NAMESPACE, '/destinations/' . self::ID_PATTERN . '/recent', [ ['methods' => 'GET', 'callback' => [$this, 'recent'], 'permission_callback' => [Routes::class, 'canManage']], ]); register_rest_route(Routes::NAMESPACE, '/destinations/' . self::ID_PATTERN . '/mapping-fields', [ ['methods' => 'GET', 'callback' => [$this, 'mappingFields'], 'permission_callback' => [Routes::class, 'canManage']], ]); register_rest_route(Routes::NAMESPACE, '/destinations/' . self::ID_PATTERN . '/draft-preview', [ ['methods' => 'POST', 'callback' => [$this, 'draftPreview'], 'permission_callback' => [Routes::class, 'canManage']], ]); register_rest_route(Routes::NAMESPACE, '/destinations/' . self::ID_PATTERN . '/draft-test', [ ['methods' => 'POST', 'callback' => [$this, 'draftTest'], 'permission_callback' => [Routes::class, 'canManage']], ]); // ==================================================================== // TWO VERBS, NOT ONE, AND THE DIFFERENCE IS WORTH KEEPING. // ==================================================================== // *Test connection* asks whether the credentials are good, and is the // useful thing on a [[Connection]] the merchant has just pasted a key // into. *Test send* pushes a synthetic subject through the whole path // and is the useful thing when the connection is fine and the [[Lead]] // is not arriving. Collapsing them leaves the second question // unanswerable, which is the one a support ticket is usually about. // // **Both are POST** even though neither writes anything WConvert // stores. They act on a remote system, they are not idempotent from // the merchant's point of view — a test send really does send — and a // GET that emails somebody is a GET a prefetcher can fire. register_rest_route(Routes::NAMESPACE, '/destinations/' . self::ID_PATTERN . '/test-connection', [ [ 'methods' => 'POST', 'callback' => [$this, 'testConnection'], 'permission_callback' => [Routes::class, 'canManage'], ], ]); register_rest_route(Routes::NAMESPACE, '/destinations/' . self::ID_PATTERN . '/test-send', [ [ 'methods' => 'POST', 'callback' => [$this, 'testSend'], 'permission_callback' => [Routes::class, 'canManage'], 'args' => [ // Explicit, never silently taken from the WordPress profile. // Identifier::email validates the same value a capture uses; // sanitizing first could turn a typo into another address. 'email' => ['type' => 'string'], 'interest' => ['type' => 'string'], ], ], ]); } public function index(): WP_REST_Response { return new WP_REST_Response([ // A suggestion displayed before Send, not permission to send it. // The suggested sample contains email only. An interest value must // be entered explicitly; no answer, name or phone is fabricated. 'test_sample' => [ 'email' => Identifier::email(wp_get_current_user()->user_email), 'fields' => ['email'], ], 'types' => array_values(array_map( fn (DestinationType $type): array => [ 'id' => $type->id(), 'label' => $type->label(), 'icon' => $type->icon(), 'tier' => $type->tier()->value, 'requires' => $type->requires()?->value, 'availability' => $this->registry->availabilityOf($type->id())->value, 'needs_connection' => $type->connectionSchema() !== null, 'supports_mapping' => method_exists($type, 'mappingFields'), // Sends every field and answer as it is, so there is nothing to map and its rows say so. 'forwards_submission' => $type instanceof \WConvert\Destination\ForwardsWholeSubmission, 'connection_schema' => $type->connectionSchema(), 'requirements' => $type->requirements()->toArray(), // What a merchant is missing, in words. The enum's value // is a slug — interpolating `mailpoet` into "Needs %s on this // site" produces copy no merchant can act on, and a // translator cannot fix it from their end either. 'requires_label' => $type->requires()?->label(), // The fields this type offers, which is what removes the // `Supports*` capability split: list discovery, custom // fields and the configuration UI all fall out of one // method (#4). 'settings_schema' => $type->connectionSchema() === null ? $this->safeSchema($type, null) : [], ], $this->registry->all() )), 'destinations' => $this->configured(), 'connections' => array_values(array_map( fn (\WConvert\Destination\Connection $connection): array => $connection->masked() + $this->setUpOn($connection), $this->connections->all() )), // A staging copy still sends to the live receiver. Said, never blocked (ADR 0148). 'environment' => wp_get_environment_type(), // The ring, which is the only place a Lead-specific terminal // failure is visible at all — health cannot see one, because a // malformed address is not an outage (ADR 0008). 'failures' => $this->failures->all(), ]); } /** Metadata is loaded only for the account the merchant selected. */ public function selectedSchema(WP_REST_Request $request): WP_REST_Response|WP_Error { $type = $this->registry->find((string) $request->get_param('type')); if ($type === null) return $this->badConnection(); $connection = $this->connections->find((string) $request->get_param('connection')); if ($type->connectionSchema() !== null && ($connection === null || $connection->type !== $type->id())) return $this->badConnection(); try { return new WP_REST_Response(['settings_schema' => $this->schemaFor($type, $connection, $request->get_param('refresh') === '1')]); } catch (\Throwable $failure) { return new WP_Error('wconvert_metadata_unavailable', __('Could not load the selected account’s lists. Check the account and try again.', 'wconvert'), ['status' => 503]); } } /** * Candidate credentials are checked before replacing a working key. * * A schema field is a string the merchant types unless its `type` says * otherwise, and three kinds say otherwise: * * - `secret` — **generated here, never accepted from the browser**, and * handed back once in `revealed`, on the save that made it. After that it * is masked like any credential (ADR 0148). * - `site_url` — the site the Connection was made on, recorded once, so a * staging copy can tell it is one. * - `headers` — a short list of name/value pairs. A pair sent with an * empty value keeps the value it already had, because the browser only * ever sees names. */ public function saveConnection(WP_REST_Request $request): WP_REST_Response|WP_Error { $id = self::optionalString($request->get_param('id')); $existing = $id === null ? null : $this->connections->find($id); if ($id !== null && $existing === null) return $this->badConnection(); $typeId = $existing === null ? (string) $request->get_param('type') : $existing->type; $type = $this->registry->find($typeId); if ($type === null || $type->connectionSchema() === null || !$this->registry->isDispatchable($typeId)) return $this->badConnection(); if ($existing !== null && array_key_exists('type', $request->get_params()) && $request->get_param('type') !== $typeId) return $this->badConnection(); $incoming = $request->get_param('credentials'); if (!is_array($incoming)) $incoming = []; $credentials = $existing === null ? [] : $existing->credentials; $revealed = []; foreach ($type->connectionSchema() as $key => $field) { $kind = is_array($field) ? ($field['type'] ?? '') : ''; if ($kind === 'secret') { if (!isset($credentials[$key]) || $credentials[$key] === '') $credentials[$key] = $revealed[$key] = self::secret($field); continue; } if ($kind === 'site_url') { $credentials[$key] ??= home_url(); continue; } if (!array_key_exists($key, $incoming)) continue; if ($kind === 'headers') { $headers = self::headers($incoming[$key], $field, is_array($credentials[$key] ?? null) ? $credentials[$key] : []); if ($headers === null) return $this->badConnection(); $credentials[$key] = $headers; continue; } $value = $incoming[$key]; if (!is_string($value) || trim($value) === '' || str_contains($value, '•')) return $this->badConnection(); $credentials[$key] = trim($value); } foreach ($type->connectionSchema() as $key => $field) { if (is_array($field) && ($field['optional'] ?? false) === true) continue; if (!isset($credentials[$key]) || $credentials[$key] === '') return $this->badConnection(); } try { $type->testConnection($credentials); $identity = method_exists($type, 'accountIdentity') ? $type->accountIdentity($credentials) : null; if ($existing !== null && $credentials !== $existing->credentials) { if (!is_string($identity) || $identity === '' || !is_string($existing->accountIdentity) || !hash_equals($existing->accountIdentity, $identity)) { return new WP_Error('wconvert_account_changed', __('This key belongs to a different or unverified account. Connect it as a new account instead.', 'wconvert'), ['status' => 409]); } } } catch (\WConvert\Destination\ConnectionRefused $refused) { // A type's own sentence, written for the merchant ({@see ConnectionRefused}). return new WP_Error('wconvert_connection_check_failed', DiagnosticSanitizer::message($refused->getMessage(), [], $credentials), ['status' => 400]); } catch (\Throwable $failure) { return new WP_Error('wconvert_connection_check_failed', __('The account check failed. Your saved credentials have not changed.', 'wconvert'), ['status' => 400]); } $label = self::named($request->get_param('label')) ?? ($existing === null ? $type->label() : $existing->label); $saved = $this->connections->save($id, $typeId, $label, $credentials, is_string($identity) ? $identity : null); return new WP_REST_Response(['connection' => $saved->masked()] + ($revealed === [] ? [] : ['revealed' => $revealed])); } /** * Replace every generated secret on one Connection, **switching at once.** * * There is no overlap window: the next attempt signs with the new secret, * so a receiver still holding the old one refuses it and the Destination * needs attention until the merchant pastes the new one in (ADR 0148). */ public function regenerateConnection(WP_REST_Request $request): WP_REST_Response|WP_Error { $connection = $this->connections->find((string) $request->get_param('id')); if ($connection === null) return $this->badConnection(); $type = $this->registry->find($connection->type); if ($type === null || !$this->registry->isDispatchable($connection->type)) return $this->badConnection(); $credentials = $connection->credentials; $revealed = []; foreach ($type->connectionSchema() ?? [] as $key => $field) { if (is_array($field) && ($field['type'] ?? '') === 'secret') $credentials[$key] = $revealed[$key] = self::secret($field); } if ($revealed === []) return $this->badConnection(); $saved = $this->connections->save($connection->id, $connection->type, $connection->label, $credentials, $connection->accountIdentity); return new WP_REST_Response(['connection' => $saved->masked(), 'revealed' => $revealed]); } /** 32 random bytes, base64, after the field's own prefix — the Standard Webhooks shape. * @param array $field */ private static function secret(array $field): string { return (is_string($field['prefix'] ?? null) ? $field['prefix'] : '') . base64_encode(random_bytes(32)); } /** * A headers field as sent, checked, or null where any pair is refused. * * A name is an HTTP token and a value has no line break, so no header can * smuggle in another. The field's `reserved` names — a signature, the * request's own framing — are refused rather than silently dropped, so the * merchant learns their header will not be sent. * * @param array $field * @param array $current The stored pairs, read back unchecked. * @return list|null */ private static function headers(mixed $incoming, array $field, array $current): ?array { $max = is_int($field['max'] ?? null) ? $field['max'] : 5; if (!is_array($incoming) || !array_is_list($incoming) || count($incoming) > $max) return null; $reserved = array_map('strtolower', is_array($field['reserved'] ?? null) ? $field['reserved'] : []); $known = []; foreach ($current as $pair) { if (is_array($pair) && is_string($pair['name'] ?? null)) $known[strtolower($pair['name'])] = (string) ($pair['value'] ?? ''); } $headers = []; $seen = []; foreach ($incoming as $pair) { $name = is_array($pair) && is_string($pair['name'] ?? null) ? trim($pair['name']) : ''; $value = is_array($pair) && is_string($pair['value'] ?? null) ? trim($pair['value']) : null; $lower = strtolower($name); if (preg_match('/^[A-Za-z0-9!#$%&\'*+.^_`|~-]{1,64}$/D', $name) !== 1 || in_array($lower, $reserved, true) || isset($seen[$lower])) return null; if ($value === null || preg_match('/[\x00-\x1F\x7F]/', $value) === 1 || strlen($value) > 1024 || str_contains($value, '•')) return null; if ($value === '') { if (!isset($known[$lower])) return null; $value = $known[$lower]; } $seen[$lower] = true; $headers[] = ['name' => $name, 'value' => $value]; } return $headers; } public function checkConnection(WP_REST_Request $request): WP_REST_Response|WP_Error { $connection = $this->connections->find((string) $request->get_param('id')); if ($connection === null) return $this->badConnection(); $type = $this->registry->find($connection->type); if ($type === null) return $this->badConnection(); try { $type->testConnection($connection->credentials); $this->connections->recordCheck($connection->id, true); return new WP_REST_Response(['outcome' => 'success', 'message' => __('Account and audience access checked.', 'wconvert')]); } catch (\Throwable $failure) { $this->connections->recordCheck($connection->id, false); return new WP_REST_Response(['outcome' => 'failed', 'message' => __('Account check failed. Check the key and provider access.', 'wconvert')]); } } public function deleteConnection(WP_REST_Request $request): WP_REST_Response|WP_Error { $id = (string) $request->get_param('id'); foreach ($this->destinations->all() as $destination) { if ($destination->connectionId === $id) { return new WP_Error('wconvert_connection_in_use', __('This account is used by a destination. Reassign or remove that destination first.', 'wconvert'), ['status' => 409]); } } if (!$this->connections->delete($id)) return $this->badConnection(); return new WP_REST_Response(['deleted' => true]); } private function badConnection(): WP_Error { return new WP_Error('wconvert_invalid_connection', __('Choose a valid account for this service.', 'wconvert'), ['status' => 400]); } /** A failed provider metadata read must not break all other integrations. */ /** @return array */ private function safeSchema(DestinationType $type, ?\WConvert\Destination\Connection $connection): array { try { return $this->schemaFor($type, $connection); } catch (\Throwable $failure) { return []; } } /** * @return WP_REST_Response|WP_Error */ public function store(WP_REST_Request $request) { $type = (string) $request->get_param('type'); $registered = $this->registry->find($type); if ($registered === null) { return new WP_Error( 'wconvert_unknown_destination_type', __('That Destination type is not available on this site.', 'wconvert'), ['status' => 400] ); } $settings = $request->get_param('settings'); $connection = $request->get_param('connection'); $connectionId = self::optionalString($connection); if ($registered->connectionSchema() !== null) { $account = $connectionId === null ? null : $this->connections->find($connectionId); if ($account === null || $account->type !== $type) return $this->badConnection(); } elseif ($connectionId !== null) { return $this->badConnection(); } $id = self::optionalString($request->get_param('id')); if ($id !== null && ($this->destinations->find($id)?->type !== $type)) { return new WP_Error('wconvert_invalid_destination', __('This destination cannot change its service.', 'wconvert'), ['status' => 400]); } $this->destinations->save( $id, $type, // **A route is named by the merchant, and a nameless one still // has to be findable.** Two MailPoet Destinations differ only in // what they point at, so the name is the thing a merchant reads // on the Optin tab — and an empty string there would render a // checkbox with no words beside it. The type's own label is what // the screen used to send for every Destination, so it is the // fallback rather than an invention. self::named($request->get_param('label')) ?? $registered->label(), self::optionalString($connection), is_array($settings) ? $settings : [] ); return new WP_REST_Response(['destinations' => $this->configured()]); } /** * @return WP_REST_Response|WP_Error */ public function destroy(WP_REST_Request $request) { $id = (string) $request->get_param('id'); if (!$this->destinations->delete($id)) { return new WP_Error( 'wconvert_destination_not_found', __('That Destination no longer exists.', 'wconvert'), ['status' => 404] ); } // Its health goes with it. Health keyed by an id nothing references is // a row that never goes away on a site that has reconfigured its // integrations a few times. $this->health->forget($id); return new WP_REST_Response(['destinations' => $this->configured()]); } /** * Replay every Lead for Optins bound to this Destination since its last * success — the one recovery action, and **merchant-driven, never * automatic** (ADR 0008). */ public function repush(WP_REST_Request $request): WP_REST_Response { return new WP_REST_Response($this->rePush->run((string) $request->get_param('id'))->toArray()); } public function recent(WP_REST_Request $request): WP_REST_Response|WP_Error { $id = (string) $request->get_param('id'); if ($this->destinations->find($id) === null) return $this->gone(); return new WP_REST_Response(['attempts' => RecentPushHistory::recent($id)]); } public function mappingFields(WP_REST_Request $request): WP_REST_Response|WP_Error { $destination = $this->destinations->find((string) $request->get_param('id')); if ($destination === null) return $this->gone(); $type = $this->registry->find($destination->type); if ($type === null || !method_exists($type, 'mappingFields')) return new WP_REST_Response(['fields' => []]); try { return new WP_REST_Response(['fields' => $this->fieldsFor($destination, $type, $request->get_param('refresh') === '1')]); } catch (\Throwable $failure) { return new WP_Error('wconvert_mapping_fields_unavailable', __('Could not load this destination’s fields. Check its account and target.', 'wconvert'), ['status' => 503]); } } /** Preview is a server-validated projection and creates no Contact. */ public function draftPreview(WP_REST_Request $request): WP_REST_Response|WP_Error { $mapped = $this->draftMapped($request); if ($mapped instanceof WP_Error) return $mapped; return new WP_REST_Response(['email' => Identifier::email((string) $request->get_param('email')), 'mapped' => $mapped]); } /** * Explicit sample send through the same adapter, with no Lead or queue job. * * A type that forwards the submission whole — a webhook — has nothing to * map, and takes the campaign's questions instead, with the merchant's sample * answers, so a receiver that learns its fields from a sample can map this * campaign's before its first real Lead (ADR 0148). */ public function draftTest(WP_REST_Request $request): WP_REST_Response|WP_Error { $destination = $this->destinations->find((string) $request->get_param('id')); $type = $destination === null ? null : $this->registry->find($destination->type); if ($type instanceof \WConvert\Destination\ForwardsWholeSubmission && $destination !== null) { $email = Identifier::email((string) $request->get_param('email')); $answers = self::draftAnswers($request->get_param('answers')); if ($email === null || $answers === null) return new WP_Error('wconvert_invalid_sample', __('Check the sample answers and test email address.', 'wconvert'), ['status' => 400]); $optin = $request->get_param('optin'); $result = $this->dispatcher->test($destination->id, ['email' => $email], [], $answers, is_string($optin) && Ulid::isOne($optin) ? $optin : null); return $this->reported(TestReport::of($result, $destination->label, ''), ['email' => $email], $this->connections->credentialsFor($destination)); } $mapped = $this->draftMapped($request); if ($mapped instanceof WP_Error) return $mapped; $destination = $this->destinations->find((string) $request->get_param('id')); if ($destination === null) return $this->gone(); $email = Identifier::email((string) $request->get_param('email')); if ($email === null) return new WP_Error('wconvert_invalid_sample', __('Enter a valid test email address.', 'wconvert'), ['status' => 400]); $result = $this->dispatcher->test($destination->id, ['email' => $email], $mapped); return $this->reported(TestReport::of($result, $destination->label, $result->outcome === PushOutcome::Success ? ($this->targetOf($destination) ?? '') : ''), ['email' => $email, ...$mapped], $this->connections->credentialsFor($destination)); } /** * A campaign's questions with sample answers, or null where any is not one. * * @return array}>|null */ private static function draftAnswers(mixed $answers): ?array { if (!is_array($answers) || !array_is_list($answers) || count($answers) > 32) return null; $text = static fn (mixed $value, int $max): bool => is_string($value) && mb_strlen($value) <= $max; $kept = []; foreach ($answers as $answer) { $id = is_array($answer) ? ($answer['id'] ?? null) : null; $value = is_array($answer) ? ($answer['answer'] ?? null) : null; if (!is_string($id) || preg_match('/^[A-Za-z0-9_-]{1,64}$/D', $id) !== 1 || !$text($answer['question'] ?? null, 200)) return null; $listed = is_array($value) && array_is_list($value) && count($value) <= 20 && array_filter($value, static fn ($part) => !$text($part, 500)) === []; if (!$listed && !$text($value, 500)) return null; $kept[$id] = ['question' => sanitize_text_field($answer['question']), 'answer' => is_array($value) ? array_values(array_map('sanitize_text_field', $value)) : sanitize_text_field($value)]; } return $kept; } /** @return array|WP_Error */ private function draftMapped(WP_REST_Request $request): array|WP_Error { $destination = $this->destinations->find((string) $request->get_param('id')); if ($destination === null) return $this->gone(); $email = Identifier::email((string) $request->get_param('email')); if ($email === null) return new WP_Error('wconvert_invalid_sample', __('Enter a valid test email address.', 'wconvert'), ['status' => 400]); $mapping = $request->get_param('mapping'); $sample = $request->get_param('sample'); if (!is_array($mapping) || !is_array($sample) || count($mapping) > 32) return new WP_Error('wconvert_invalid_sample', __('Check the draft mapping and sample values.', 'wconvert'), ['status' => 400]); $type = $this->registry->find($destination->type); if ($type === null || !method_exists($type, 'mappingFields')) return new WP_Error('wconvert_invalid_sample', __('This destination has no extra fields.', 'wconvert'), ['status' => 400]); try { $fields = $this->fieldsFor($destination, $type); } catch (\Throwable $failure) { return new WP_Error('wconvert_mapping_fields_unavailable', __('Could not check this destination’s fields.', 'wconvert'), ['status' => 503]); } $allowed = array_column($fields, null, 'value'); $mapped = []; $used = []; foreach ($mapping as $source => $target) { if (!is_string($source) || !is_string($target) || !isset($allowed[$target]) || isset($used[$target]) || (str_starts_with($source, 'choice:') !== (($allowed[$target]['type'] ?? 'text') === 'boolean'))) { return new WP_Error('wconvert_invalid_mapping', __('Choose one available destination field for each answer.', 'wconvert'), ['status' => 400]); } $used[$target] = true; $value = $sample[$source] ?? null; if (str_starts_with($source, 'choice:')) { if (!is_bool($value)) return new WP_Error('wconvert_invalid_sample', __('Choose whether each sample interest is selected.', 'wconvert'), ['status' => 400]); if ($value) $mapped[$target] = true; continue; } if (!is_string($value) || mb_strlen($value) > 500) return new WP_Error('wconvert_invalid_sample', __('Enter a short sample for every mapped answer.', 'wconvert'), ['status' => 400]); if (trim($value) !== '') $mapped[$target] = trim($value); } return $mapped; } /** * **Are these credentials good?** * * A different question from whether a push lands, and the useful one on a * Connection the merchant has just created — which is why it is a separate * button rather than a branch inside the other one. * * A type with no Connection says so rather than reporting success: every * free type is in that state, and a green tick there would teach a * merchant that this button means *"this works"* when the other button is * what means that. * * @return WP_REST_Response|WP_Error */ public function testConnection(WP_REST_Request $request) { $destination = $this->destinations->find((string) $request->get_param('id')); if ($destination === null) { return $this->gone(); } $type = $this->registry->find($destination->type); if ($type === null || !$this->registry->isDispatchable($destination->type)) { // The same sentence a test send gets for the same situation, and // the same object producing it, so a merchant with no MailPoet reads // one answer from both buttons rather than two. return $this->reported(TestReport::unavailable()); } if ($type->connectionSchema() === null) { return $this->reported(TestReport::nothingToConnectTo()); } $credentials = $this->connections->credentialsFor($destination); try { $type->testConnection($credentials); } catch (\Throwable $refused) { // Keep the provider's actionable wording. `reported()` removes // configured secrets before React renders the answer. // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- rendered through React, which escapes; see WpMailPoetSubscribers::add(). return $this->reported(TestReport::failed($refused->getMessage()), [], $credentials); } return $this->reported(TestReport::connected($destination->label)); } /** * **Push a synthetic subject through the real `push()`.** * * Through the REAL one, which is most of the point: a code path that * behaves differently under test proves the code path and not the * integration ({@see \WConvert\Destination\PushSubject}). What differs is * where the values came from, and nothing else. * * The merchant reviews the sample address and saved route before sending. * The profile is a visible suggestion on the read payload, never an implicit * recipient here. The same identifier normalizer as capture validates it; * the existing provider still decides whether the push can succeed. * * It writes no [[Lead]], queues nothing, and moves no counter: * {@see PushDispatcher::test()} is where that is guaranteed, and this * route adds nothing that could undo it. * * @return WP_REST_Response|WP_Error */ public function testSend(WP_REST_Request $request) { $destination = $this->destinations->find((string) $request->get_param('id')); if ($destination === null) { return $this->gone(); } // **Asked before the address**, because a type this install cannot run // has nothing to send wherever the address came from — and telling a // merchant with no MailPoet to check their WordPress profile would send // them to fix the wrong thing. if (!$this->registry->isDispatchable($destination->type)) { return $this->reported(TestReport::unavailable()); } $email = Identifier::email(self::optionalString($request->get_param('email')) ?? ''); if ($email === null) { return $this->reported(TestReport::failed( __('Enter a valid email address for this test. Nothing has been sent.', 'wconvert') )); } $sample = ['email' => $email]; $interest = $request->get_param('interest'); if ($interest !== null) { if (!is_string($interest) || trim($interest) === '') { return $this->reported(TestReport::failed(__('Enter an interest value or leave it out of the test. Nothing has been sent.', 'wconvert'))); } $sample['interest'] = trim($interest); } $result = $this->dispatcher->test($destination->id, $sample); // **Only where it landed.** Naming a target is what a SUCCESS sentence // is for, and resolving one costs a schema read that may reach the // provider — so a Destination that is down is not asked a second // question on its way to reporting the first answer. return $this->reported(TestReport::of( $result, $destination->label, // `?? ''` and not a second sentence: a Destination that selects // nothing, and one whose provider could not be reached, both come // back null here — and {@see TestReport::of()} already says the // shorter *"the test reached X"* for an unnamed target. $result->outcome === PushOutcome::Success ? ($this->targetOf($destination) ?? '') : '' ), $sample, $this->connections->credentialsFor($destination)); } /** * What this Destination is pointed at — for the success sentence, and for * the line under every Destination on both screens that render one. * * **Null is a real answer here, three times over.** Two of them are * {@see ConfiguredTarget}'s: a type that selects nothing, and a provider * whose options could not be enumerated. The third is this method's own, * and it is the reason the `catch` does not fall back to `''`: **a schema * read can reach the wire**, so an ESP having a bad time means we could * not LOOK — which is a different thing from looking and finding nothing * chosen. Saying *"not pointed at anything yet"* there would report a * fault against a Destination that is pushing perfectly well, on the * screen whose whole job is to say whether things are working (ADR 0042). * * It also keeps a push that LANDED from being turned into a 500 by a * second question asked on the way to reporting the first answer. */ private function targetOf(\WConvert\Destination\Destination $destination): ?string { $type = $this->registry->find($destination->type); if ($type === null) { return null; } try { // Resolve options using this Destination's own Connection. $schema = $this->schemaFor( $type, $destination->connectionId === null ? null : $this->connections->find($destination->connectionId) ); } catch (\Throwable $unreachable) { unset($unreachable); return null; } return ConfiguredTarget::of($schema, $destination->settings); } /** * @param array $personal * @param array $secrets */ private function reported(TestReport $report, array $personal = [], array $secrets = []): WP_REST_Response { $payload = $report->toArray(); $payload['message'] = DiagnosticSanitizer::message($payload['message'], $personal, $secrets); return new WP_REST_Response($payload); } private function gone(): WP_Error { return new WP_Error( 'wconvert_destination_not_found', __('That Destination no longer exists.', 'wconvert'), ['status' => 404] ); } /** * One type's settings schema, **read once per request per account.** * * ======================================================================== * THE MEMO IS THE POINT, NOT AN OPTIMISATION. * ======================================================================== * `settingsSchema()` may reach the provider. Remote schemas are loaded on * demand for the selected Connection; configured target labels reuse the * same request-local read for routes sharing that account. * * **Keyed by the Connection**, because the Connection is what changes the * answer: two Destinations over one key read one schema, and two ACCOUNTS * are two keys and two schemas whose id→label maps disagree. A type with * no Connection is keyed by the type instead, under a prefix no ULID can * collide with. * * A failed read is remembered too. Callers turn it into a local error or * an absent target label without failing the entire destinations page. * * @return array */ private function schemaFor(DestinationType $type, ?\WConvert\Destination\Connection $connection, bool $refresh = false): array { $key = $connection === null ? 'type:' . $type->id() : $connection->id; if ($refresh || !array_key_exists($key, $this->schemas)) { try { $cacheKey = $connection === null ? null : $this->metadataKey('schema', $type, $connection); $cached = !$refresh && $cacheKey !== null && function_exists('get_transient') ? get_transient($cacheKey) : false; $schema = is_array($cached) ? $cached : $type->settingsSchema($connection === null ? [] : $connection->credentials); if ($cacheKey !== null && !is_array($cached) && function_exists('set_transient')) set_transient($cacheKey, $schema, 300); $this->schemas[$key] = $schema; } catch (\Throwable $unreachable) { $this->schemas[$key] = $unreachable; } } $schema = $this->schemas[$key]; if ($schema instanceof \Throwable) { throw $schema; } return $schema; } /** @return list */ private function fieldsFor(\WConvert\Destination\Destination $destination, DestinationType $type, bool $refresh = false): array { return MappingFields::for($type, $destination, $this->connections->credentialsFor($destination), $refresh); } /** Opaque cache key changes when credentials or the selected target change. */ /** @param array $settings */ private function metadataKey(string $kind, DestinationType $type, \WConvert\Destination\Connection $connection, array $settings = []): string { return 'wconvert_meta_' . substr(hash('sha256', (string) wp_json_encode([$kind, $type->id(), $connection->id, $connection->credentials, $settings])), 0, 40); } /** * Configured Destinations, each with the health beside it — which is the * only thing on this screen a merchant is actually looking for. * * @return list> */ private function configured(): array { $health = $this->health->all(); $usage = $this->usage?->all(); $rows = []; foreach ($this->destinations->all() as $destination) { $rows[] = $destination->toArray() + [ 'id' => $destination->id, 'availability' => $this->registry->availabilityOf($destination->type)->value, 'health' => ($health[$destination->id] ?? new \WConvert\Destination\DestinationHealth())->toArray(), // **Derived, never stored and never posted back.** It is // computed HERE rather than in the browser because // {@see ConfiguredTarget} is the one spelling of the rule, and // because the client only has the per-TYPE schema — built // from the first Connection of that type, which names the // wrong audience for the second account (#35). 'target' => $this->targetOf($destination), 'requirements' => $this->registry->find($destination->type)?->requirements()->toArray(), 'mapping_issues' => $this->mappingIssues($destination), 'usage' => $usage === null ? null : ($usage[$destination->id] ?? []), ]; } return $rows; } /** @return list */ private function mappingIssues(\WConvert\Destination\Destination $destination): array { $type = $this->registry->find($destination->type); if ($type === null) return []; $issues = []; foreach ($type->requirements()->mappedFields as $field) { $selected = \WConvert\Destination\DestinationRequirements::text($destination->settings[$field['setting']] ?? null); if ($selected === '') continue; try { $schema = $this->schemaFor($type, $destination->connectionId === null ? null : $this->connections->find($destination->connectionId)); $options = $schema[$field['setting']]['options'] ?? []; if (!is_array($options) || !in_array($selected, array_column($options, 'value'), true)) { /* translators: %s: the label of a form field, for example "Email". */ $issues[] = sprintf(__('The selected field for %s is unavailable. Review this destination’s mapping.', 'wconvert'), $field['label']); } } catch (\Throwable $failure) { $issues[] = __('The provider fields could not be checked. Refresh before relying on the answer mapping.', 'wconvert'); } } return $issues; } /** * Whether this Connection was made on a site other than this one — a * staging or cloned copy — and, where it was, that site's host for the * row's note. Read through its type's `site_url` field, so free names no * type's own keys. * * @return array{site_changed: bool, set_up_on?: string} */ private function setUpOn(\WConvert\Destination\Connection $connection): array { foreach ($this->registry->find($connection->type)?->connectionSchema() ?? [] as $key => $field) { if (!is_array($field) || ($field['type'] ?? '') !== 'site_url') continue; $setup = $connection->credentials[$key] ?? null; if (is_string($setup) && $setup !== '' && rtrim($setup, '/') !== rtrim(home_url(), '/')) { $host = wp_parse_url($setup, PHP_URL_HOST); return ['site_changed' => true] + (is_string($host) && $host !== '' ? ['set_up_on' => $host] : []); } } return ['site_changed' => false]; } /** * @param mixed $value */ private static function optionalString($value): ?string { return is_string($value) && $value !== '' ? $value : null; } /** * A merchant's name for a route, or null where they gave none. * * Whitespace is none: a name made of spaces renders as a checkbox with * nothing beside it, which is the same failure as an empty one and arrives * by the same accident. * * @param mixed $value */ private static function named($value): ?string { return is_string($value) && trim($value) !== '' ? trim($value) : null; } }