register('webhook', $handler, $controls, 'Webhook'); * * A handler is `function (Submission $submission, array $settings, Actions $actions)` and returns * `null|true` (done), an array merged into the JSON response (e.g. `['redirect' => [...]]`), or a * `WP_Error` (logged through `zadex/forms/action_failed`; the visitor still sees success when the * submission was stored, see SubmitController). * * `$controls` are manifest control definitions (text, select, switch ...). For actions that are not * built into widgets/form/manifest.json, `extend_manifest()` (hooked to `zadex/widgets/manifests`) adds * the action to the widget's "Actions" checklist plus a settings section `action_` that is only shown * while the action is checked. Integrations (webhooks, email marketing, Slack ...) register through the * `zadex/forms/actions` filter or the `zadex/forms/register_actions` action. * * Built-ins: collect (store in the submissions table, always runs first), email, email_2, redirect, popup. */ final class Actions { public const BUILTIN = ['collect', 'email', 'email_2', 'redirect', 'popup']; /** @var array}> */ private array $actions = []; private bool $filtered = false; private ?Submissions $submissions; public function __construct(?Submissions $submissions = null, bool $builtins = true) { $this->submissions = $submissions; if ($builtins) { $this->register_builtins(); } } /** @param array> $controls */ public function register(string $id, callable $handler, array $controls = [], string $label = ''): void { $id = sanitize_key($id); if ($id === '') { return; } $this->actions[$id] = ['id' => $id, 'label' => $label !== '' ? $label : ucfirst(str_replace(['_', '-'], ' ', $id)), 'handler' => $handler, 'controls' => array_values($controls)]; } public function unregister(string $id): void { unset($this->actions[$id]); } /** @return array}> */ public function all(): array { if (!$this->filtered) { $this->filtered = true; do_action('zadex/forms/register_actions', $this); $filtered = apply_filters('zadex/forms/actions', $this->actions, $this); if (is_array($filtered)) { $this->actions = array_filter($filtered, static fn($a): bool => is_array($a) && isset($a['handler']) && is_callable($a['handler'])); foreach ($this->actions as $id => $a) { $this->actions[$id] += ['id' => (string) $id, 'label' => (string) $id, 'controls' => []]; } } } return $this->actions; } public function has(string $id): bool { return isset($this->all()[$id]); } /** * Runs the chosen actions. `collect` always runs first so later actions can reference the stored row. * * @param array $ids * @return array{response: array, errors: array, ran: array} */ public function run(array $ids, Submission $submission): array { $all = $this->all(); $ids = array_values(array_unique(array_filter(array_map('strval', $ids), static fn(string $id): bool => isset($all[$id])))); usort($ids, static fn(string $a, string $b): int => ($a === 'collect' ? 0 : 1) <=> ($b === 'collect' ? 0 : 1)); $response = []; $errors = []; $ran = []; foreach ($ids as $id) { try { $result = call_user_func($all[$id]['handler'], $submission, $submission->settings, $this); } catch (\Throwable $e) { $result = new WP_Error('zadex_action_exception', $e->getMessage()); } $ran[] = $id; if ($result instanceof WP_Error) { $errors[$id] = $result->get_error_message(); do_action('zadex/forms/action_failed', $id, $result, $submission); continue; } if (is_array($result)) { $response = array_merge($response, $result); } } return ['response' => $response, 'errors' => $errors, 'ran' => $ran]; } /** * `zadex/widgets/manifests` filter: adds registered third-party actions to the Form widget * (checklist option + a conditional section holding the action's controls). * * @param array $manifests * @return array */ public function extend_manifest(array $manifests): array { if (!isset($manifests['form']) || !is_array($manifests['form'])) { return $manifests; } $manifest = $manifests['form']; $existing = []; foreach ($manifest['controls'] ?? [] as $c) { if (isset($c['id'])) { $existing[$c['id']] = true; } } foreach ($this->all() as $id => $action) { if (in_array($id, self::BUILTIN, true)) { continue; } foreach ($manifest['controls'] as $k => $c) { if (($c['id'] ?? '') === 'actions') { $options = (array) ($c['options'] ?? []); if (!in_array($id, array_column($options, 'value'), true)) { $options[] = ['value' => $id, 'label' => $action['label']]; } $manifest['controls'][$k]['options'] = $options; } } if (!$action['controls']) { continue; } $section = 'action_' . $id; $manifest['sections'][] = ['id' => $section, 'label' => $action['label'], 'tab' => 'content', 'condition' => ['actions' => $id]]; foreach ($action['controls'] as $control) { if (!is_array($control) || empty($control['id']) || isset($existing[$control['id']])) { continue; } $existing[$control['id']] = true; $manifest['controls'][] = array_merge($control, ['tab' => 'content', 'section' => $section]); } } $manifests['form'] = $manifest; return $manifests; } private function register_builtins(): void { // Labels stay untranslated here: this may run during plugins_loaded (before `init`), where __() would // trigger WordPress' "_load_textdomain_just_in_time" notice. Built-in labels shown in the editor come // from widgets/form/manifest.json (translated by the editor's t()). $this->register('collect', function (Submission $s): ?WP_Error { $repo = $this->submissions ?? new Submissions(); $id = $repo->insert($s); if ($id <= 0) { return new WP_Error('zadex_collect_failed', __('The submission could not be stored.', 'zadex')); } $s->id = $id; return null; }, [], 'Save submission'); $this->register('email', static fn(Submission $s, array $settings) => self::send_email($s, $settings, 'email_'), [], 'Email'); $this->register('email_2', static fn(Submission $s, array $settings) => self::send_email($s, $settings, 'email_2_'), [], 'Email 2'); $this->register('redirect', static fn(Submission $s, array $settings) => self::redirect($s, $settings), [], 'Redirect'); $this->register('popup', static function (Submission $s, array $settings): ?array { $id = isset($settings['popup_id']) && is_scalar($settings['popup_id']) ? sanitize_key((string) $settings['popup_id']) : ''; if ($id === '') { return null; } $action = ($settings['popup_action'] ?? 'open') === 'close' ? 'close' : 'open'; return ['popup' => ['id' => $id, 'action' => $action]]; }, [], 'Popup'); } /** @return array{redirect: array{url: string, delay: int}}|null */ public static function redirect(Submission $s, array $settings): ?array { $raw = isset($settings['redirect_url']) && is_string($settings['redirect_url']) ? trim($settings['redirect_url']) : ''; if ($raw === '') { return null; } $url = Shortcodes::render($raw, $s, 'url'); // Only http(s) or site-relative URLs: never javascript:, data: or protocol-relative (//evil). if (!preg_match('#^(https?://|/(?!/))#i', $url)) { return null; } $url = esc_url_raw($url); if ($url === '') { return null; } $delay = isset($settings['redirect_delay']) && is_numeric($settings['redirect_delay']) ? max(0, min(20000, (int) $settings['redirect_delay'])) : 0; return ['redirect' => ['url' => $url, 'delay' => $delay]]; } /** * Comma separated address list after shortcodes; invalid addresses are dropped. * * @return array */ public static function addresses(string $raw, Submission $s): array { $out = []; foreach (explode(',', Shortcodes::render($raw, $s, 'header')) as $part) { $email = sanitize_email(trim($part)); if ($email !== '' && is_email($email)) { $out[] = $email; } } return array_values(array_unique($out)); } /** * Builds and sends one email action (`email_` or `email_2_` settings prefix). * * @return true|WP_Error */ public static function send_email(Submission $s, array $settings, string $prefix) { $get = static fn(string $k): string => isset($settings[$prefix . $k]) && is_scalar($settings[$prefix . $k]) ? (string) $settings[$prefix . $k] : ''; $to = self::addresses($get('to'), $s); if (!$to && $prefix === 'email_') { $admin = (string) get_option('admin_email', ''); if ($admin !== '') { $to = [$admin]; } } if (!$to) { return new WP_Error('zadex_mail_no_recipient', __('No valid recipient.', 'zadex')); } $html = $get('format') !== 'plain'; $subject = Shortcodes::render($get('subject'), $s, 'header'); if ($subject === '') { /* translators: %s: form name. */ $subject = Shortcodes::header_safe(sprintf(__('New submission: %s', 'zadex'), $s->form_name)); } $body_template = $get('message') !== '' ? $get('message') : '[all-fields]'; $body = Shortcodes::render($body_template, $s, $html ? 'html' : 'plain'); $headers = ['Content-Type: ' . ($html ? 'text/html' : 'text/plain') . '; charset=UTF-8']; $from = sanitize_email(Shortcodes::render($get('from'), $s, 'header')); $from_name = str_replace(['<', '>', '"', ','], '', Shortcodes::render($get('from_name'), $s, 'header')); if ($from !== '' && is_email($from)) { $headers[] = 'From: ' . ($from_name !== '' ? $from_name . ' ' : '') . '<' . $from . '>'; } elseif ($from_name !== '') { $name = $from_name; add_filter('wp_mail_from_name', $cb = static fn(): string => $name); } $reply_field = Fields::sanitize_id($get('reply_to')); $reply = $reply_field !== '' ? sanitize_email($s->text($reply_field)) : $s->first_email(); if ($reply !== '' && is_email($reply)) { $headers[] = 'Reply-To: ' . Shortcodes::header_safe($reply); } foreach (self::addresses($get('cc'), $s) as $cc) { $headers[] = 'Cc: ' . $cc; } foreach (self::addresses($get('bcc'), $s) as $bcc) { $headers[] = 'Bcc: ' . $bcc; } $mail = apply_filters('zadex/forms/email', ['to' => $to, 'subject' => $subject, 'message' => $body, 'headers' => $headers], $s, $prefix); $sent = (bool) wp_mail($mail['to'], $mail['subject'], $mail['message'], $mail['headers']); if (isset($cb)) { remove_filter('wp_mail_from_name', $cb); } return $sent ? true : new WP_Error('zadex_mail_failed', __('The email could not be sent.', 'zadex')); } }