diff --git a/STATUS.md b/STATUS.md index 0effc99..6f06615 100644 --- a/STATUS.md +++ b/STATUS.md @@ -3,7 +3,17 @@ **Tier:** build **Board:** [PlugPress HQ](https://github.com/orgs/plugpressco/projects/3) -## Last session (2026-10-06) — simple SMTP: Pro removed, Meow Mailer parity (#17, PR #18) +## Last session (2026-10-09) — Settings → SMTP + declutter (#20, PR #21 — awaiting your merge) + +- **User ask:** remove the top-level menu, move the page to Settings → SMTP, clean up and declutter the admin UI. Filed as #20; PR #21 is ready, **CI green, NOT merged** (two hard stops below). +- **Menu:** `add_options_page()` at `options-general.php?page=mailyard`, labelled "SMTP" (page title "Mailyard SMTP"); screen id `settings_page_mailyard` (`Settings::SCREEN`). The top-level menu, its 5 submenu entries, the menu icon and the submenu click interceptor are gone. `Settings::url( $route )` is now the one way to link into the app. Old `admin.php?page=mailyard…` URLs 302 there with query args kept, and the `#/…` fragment survives. +- **UI:** the app sidebar became the design system's **topbar** shell (Overview · Connections · Email log · Deliverability · Settings · Help, no icons). Settings' grouped rail became one header + `Tabs` row, with a `SectionIntro` line per section. Dashboard is labelled **Overview**; "Data & danger" is now "Data". +- **Notices:** the send-failure notice shows on the WP Dashboard + Plugins screens only, with one dismiss path (the non-persistent × is gone). +- **CSS gotcha fixed:** `#mailyard-admin * { border-color }` had id specificity and silently overrode every authored `border-*`/`divide-*` colour plus library component borders. It's now `:where(#mailyard-admin) *`, so authored colours (e.g. the blue selected preset chip) show for the first time. +- **QA:** WP Playground via `npx @wp-playground/cli` (needs **node@22**: `PATH=/opt/homebrew/opt/node@22/bin:$PATH`; Node 26 has no fs-ext prebuild) with a scratchpad auto-login mu-plugin. All views + tabs, the legacy redirect, the OAuth-style return, the action link, notice screens + Dismiss, 600/380px widths. The local Studio site (`plugpess.wp.local`) wasn't running. Note: on that site use `studio wp …`, not bare `wp`. +- **Found + filed:** #22, the OAuth error reason never reaches the toast (core's canonical-URL script strips the `message` query arg). This was already happening before #20. + +## Earlier session (2026-10-06) — simple SMTP: Pro removed, Meow Mailer parity (#17, PR #18) - **Direction change (user):** Mailyard is a standalone, simple SMTP plugin — no Mailyard Pro, no marketing layer — at feature parity with [Meow Mailer](https://wordpress.org/plugins/meow-mailer/), keeping Mailyard's own edges (failover chain, sender routing, bounce webhooks, deliverability checker, MCP tools). Scope choice "option b": drop Pro hooks + Marketing purpose, keep everything SMTP. - **All 11 checklist items of #17 landed via PR #18, squash-merged to `main` as `c0d9d22` with `[skip actions]`, so WordPress.org was NOT synced and still shows 1.0.1: Pro shell/filters + Marketing purpose removed; send pipeline rewritten (`Message` parser — **Cc/Bcc in headers were silently dropped for every provider**, now fixed; one message to all recipients); Offline mode; Background sending; PHP Mail as a real connection; log filters/paging/CSV/retention/top errors; alerts (email unrouted, Slack/Discord/Teams/JSON webhook, weekly summary); WordPress email switches; Return path; importer (WP Mail SMTP/Easy WP SMTP/FluentSMTP/Post SMTP incl. their encryption); wp-config constants + sodium encryption + settings backup; 6 API providers + 4 OAuth (Gmail, M365, M365 app-only, Zoho); multisite shared settings; dashboard setup checks. @@ -78,6 +88,9 @@ - Regenerated `languages/mailyard.pot`; earlier full audit: 0 blockers (hardening backlog: mask connection secrets on read, webhook signature verification, React i18n — file as board issues). ## Next up +- **Merge PR #21 (needs your OK):** it touches `readme.txt`, so a plain merge to `main` fires `assets.yml` and syncs the readme (unreleased 1.1.0 changelog) to WordPress.org right away. Squash with `[skip actions]` in the title, as #18 did, unless you want it live. It's also ~520 changed lines. After merge: remove the `in-progress` label from #20 and move the card to Done. +- **#23:** add `'default' => array()` to the optional-input ability schemas. Today a plain Abilities REST call with no input 400s; MCP/Saddle are unaffected. Verified 2026-10-09: on `main` + Saddle 1.5.1 all 4 read-only tools register as `saddle/mailyard-*` and run, and live plugpress.io answers through Saddle MCP. +- **#22:** rename the OAuth `message` return arg (e.g. `mailyard_oauth_message`) in `class-oauth.php` + `Connections.jsx`. - **Release 1.1.0 when ready:** set `Version:` + `MAILYARD_VERSION` + `Stable tag` to 1.1.0 (changelog already written; header still says 1.0.2), merge, then tag `v1.1.0`. That deploys the code and syncs the readme to WordPress.org. Do the live-send QA below first. - **Delete Pro branches on origin (blocked for me):** `git push origin --delete feat/merge-pro feat/freemius-parent feat/smtp-ux-pass feat/universal-shell`. - With reads unblocked: delete the now-unused `src/hooks/useLogs.js`; have `Data_Deleter`/`uninstall.php` also clear `mailyard_network` (site option), the `mailyard_alerted_*` transients and the `mailyard_weekly_summary` cron; consider folding `Conflicts` into the new `Checks`. diff --git a/docs/documentation.md b/docs/documentation.md index 202dda5..73adf33 100644 --- a/docs/documentation.md +++ b/docs/documentation.md @@ -33,7 +33,7 @@ Amazon's Simple Email Service. Very low cost at volume ($0.10 per 1,000 emails). 1. In AWS Console → SES → Verified identities, verify your sending domain. 2. Create an IAM user with `ses:SendRawEmail` permission. 3. Generate an access key and secret for that user. -4. In Mailyard → Connections → Add → Amazon SES, paste the key, secret, and region. +4. In Settings → SMTP → Connections → Add → Amazon SES, paste the key, secret, and region. **Required fields:** Access Key ID, Secret Access Key, AWS Region (e.g. `us-east-1`). @@ -48,7 +48,7 @@ Premium transactional email. Known for the fastest delivery and best inbox place **Setup:** 1. In Postmark → Sender Signatures, add and verify your sending domain. 2. Create a Server and copy its Server API Token. -3. In Mailyard → Connections → Add → Postmark, paste the token. +3. In Settings → SMTP → Connections → Add → Postmark, paste the token. **Required fields:** Server API Token. @@ -65,7 +65,7 @@ Modern email API with a generous free tier (3,000 emails/month free). Simple RES **Setup:** 1. In Resend → Domains, add and verify your sending domain. 2. In Resend → API Keys, create a key. -3. In Mailyard → Connections → Add → Resend, paste the key. +3. In Settings → SMTP → Connections → Add → Resend, paste the key. **Required fields:** API Key. @@ -78,7 +78,7 @@ Free tier includes 300 emails per day with no monthly cap on contacts. **Setup:** 1. In Brevo → Senders & Domains, authenticate your domain (adds DKIM and SPF records). 2. In Brevo → SMTP & API → API Keys, create a key. -3. In Mailyard → Connections → Add → Brevo, paste the key. +3. In Settings → SMTP → Connections → Add → Brevo, paste the key. **Required fields:** API Key. @@ -90,7 +90,7 @@ Use any SMTP server: Gmail app passwords, Microsoft 365, Mailgun SMTP, your own **Setup:** 1. Gather your SMTP host, port, encryption type, and credentials from your provider. -2. In Mailyard → Connections → Add → Custom SMTP, fill in the fields. +2. In Settings → SMTP → Connections → Add → Custom SMTP, fill in the fields. **Required fields:** SMTP Host, Port (default 587). @@ -113,7 +113,7 @@ Uses WordPress's own `wp_mail()` without modification — whatever your hosting ### Priority order -Connections are attempted in priority order (1 = primary, 2 = first backup, etc.). Drag and drop in Mailyard → Connections to reorder them. +Connections are attempted in priority order (1 = primary, 2 = first backup, etc.). Drag and drop in Settings → SMTP → Connections to reorder them. The **primary** connection handles every email under normal conditions. If it returns an error, Mailyard immediately tries the next enabled connection — still inside the same `wp_mail()` call. @@ -144,7 +144,7 @@ The routing engine filters connections by purpose before selecting the chain. ## Email logging -When logging is enabled (Settings → Email logging), every outgoing email is recorded to a custom database table (`wp_mailyard_logs`). +When logging is enabled (Settings → SMTP → Settings → Delivery → Email logging), every outgoing email is recorded to a custom database table (`wp_mailyard_logs`). ### What is logged @@ -161,7 +161,7 @@ When logging is enabled (Settings → Email logging), every outgoing email is re ### Viewing logs -Go to Mailyard → Logs. You can: +Go to Settings → SMTP → Email log. You can: - Filter by status (All / Sent / Failed) - Search by recipient address or subject - Page through results (20 per page) @@ -170,13 +170,13 @@ Go to Mailyard → Logs. You can: Logs older than 30 days are deleted automatically via a daily WP-Cron job (`mailyard_daily_cleanup`). This keeps the database from growing unbounded. -To disable logging entirely, go to Settings → Email logging and toggle it off. Existing logs are kept until they age out or you uninstall the plugin. +To disable logging entirely, go to Settings → SMTP → Settings → Delivery and switch Email logging off. Existing logs are kept until they age out or you uninstall the plugin. --- ## Deliverability checker -Mailyard → Deliverability scans your sending domain's DNS records and scores your authentication setup from 0–100. +Settings → SMTP → Deliverability scans your sending domain's DNS records and scores your authentication setup from 0–100. ### What it checks @@ -249,7 +249,7 @@ Click **Send test** to send a real email through your live routing chain. Leave ## Connect AI (Abilities API / MCP) -Mailyard registers its delivery tools on the **WordPress Abilities API** (WordPress 7.0+), so an AI assistant can diagnose email problems for you. Control everything in **Settings → Connect AI**. +Mailyard registers its delivery tools on the **WordPress Abilities API** (WordPress 7.0+), so an AI assistant can diagnose email problems for you. Control everything in **Settings → SMTP → Settings → Connect AI**. ### The tools @@ -265,14 +265,14 @@ Every tool requires the `manage_options` capability. Credentials (`config`), and ### Permissions -**Settings → Connect AI** has a master switch ("AI access") and a switch per tool. Turning the master off unregisters every Mailyard ability immediately. The four read-only tools are on by default; `mailyard/send-test-email` is OFF by default because it delivers a real email — turn it on deliberately. +**Settings → SMTP → Settings → Connect AI** has a master switch ("AI access") and a switch per tool. Turning the master off unregisters every Mailyard ability immediately. The four read-only tools are on by default; `mailyard/send-test-email` is OFF by default because it delivers a real email — turn it on deliberately. ### Connecting a client Mailyard exposes abilities but ships **no MCP server** — install a bridge, e.g. the free [WordPress MCP Adapter](https://github.com/wordpress/mcp-adapter). Then: 1. Create an Application Password (**Users → Profile → Application Passwords**). -2. Copy the endpoint from Settings → Connect AI (the "How to connect" guide has copy buttons). +2. Copy the endpoint from Settings → SMTP → Settings → Connect AI (the "How to connect" guide has copy buttons). 3. Add it to your client, e.g. Claude Code: ```bash diff --git a/includes/class-alerts.php b/includes/class-alerts.php index 2e5e680..af8a982 100644 --- a/includes/class-alerts.php +++ b/includes/class-alerts.php @@ -136,7 +136,7 @@ public function test( string $channel, string $target = '' ) { */ public static function webhook_payload( string $url, string $event, string $text, array $extra = array() ): array { $host = strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) ); - $logs = admin_url( 'admin.php?page=mailyard#/logs' ); + $logs = Settings::url( 'logs' ); if ( preg_match( '/(^|\.)slack\.com$/', $host ) ) { return array( 'text' => $text . "\n" . $logs ); @@ -183,7 +183,7 @@ private function claim( string $kind ): bool { private function notify( string $subject, array $lines, string $event, array $extra ): void { if ( ! empty( Options::settings()['alert_email'] ) ) { - $this->send_unrouted( $this->recipient(), $subject, implode( "\n\n", $lines ) . "\n\n" . __( 'The email log:', 'mailyard' ) . ' ' . admin_url( 'admin.php?page=mailyard#/logs' ) . $this->footer() ); + $this->send_unrouted( $this->recipient(), $subject, implode( "\n\n", $lines ) . "\n\n" . __( 'The email log:', 'mailyard' ) . ' ' . Settings::url( 'logs' ) . $this->footer() ); } $this->post_webhook( $event, $subject . ' — ' . implode( ' ', array_slice( $lines, 1 ) ), $extra ); } @@ -232,6 +232,7 @@ private function site_name(): string { } private function footer(): string { - return "\n\n— " . __( 'Sent by Mailyard. Change alerts in Mailyard → Settings → Alerts.', 'mailyard' ); + /* translators: %s: URL of Mailyard's alert settings. */ + return "\n\n— " . sprintf( __( 'Sent by Mailyard. Change alerts: %s', 'mailyard' ), Settings::url( 'settings/alerts' ) ); } } diff --git a/includes/class-conflicts.php b/includes/class-conflicts.php index 52ad5f6..5f5e407 100644 --- a/includes/class-conflicts.php +++ b/includes/class-conflicts.php @@ -49,7 +49,7 @@ public function maybe_show_notice(): void { } $screen = get_current_screen(); - if ( $screen && ! in_array( $screen->id, array( 'toplevel_page_mailyard', 'plugins' ), true ) ) { + if ( $screen && ! in_array( $screen->id, array( Settings::SCREEN, 'plugins' ), true ) ) { return; } diff --git a/includes/class-failure-notice.php b/includes/class-failure-notice.php index 2420661..9528705 100644 --- a/includes/class-failure-notice.php +++ b/includes/class-failure-notice.php @@ -4,8 +4,8 @@ defined( 'ABSPATH' ) || exit; /** - * Surfaces email send failures as a dismissible admin notice with a link to the - * log. Records the latest failure (fed by the `mailyard_send_failed` action) and + * Surfaces email send failures as a dismissible admin notice (Dashboard and + * Plugins screens) with a link to the log. Records the latest failure (fed by the `mailyard_send_failed` action) and * auto-clears it when a later send succeeds or the connection config changes, so * a fixed setup dismisses the warning on its own — it isn't nagware. */ @@ -82,9 +82,10 @@ public function render(): void { return; } - // The log is already on the Mailyard admin page — don't double up there. + // Only where an admin lands — the Dashboard and Plugins screens — not + // on every admin page. Mailyard's own page shows it in its overview. $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; - if ( $screen && false !== strpos( (string) $screen->id, 'page_mailyard' ) ) { + if ( ! $screen || ! in_array( $screen->id, array( 'dashboard', 'plugins' ), true ) ) { return; } @@ -98,7 +99,7 @@ public function render(): void { $title = isset( $failure['title'] ) ? $failure['title'] : __( 'Email could not be sent', 'mailyard' ); $guidance = isset( $failure['guidance'] ) ? $failure['guidance'] : ''; - $log_url = admin_url( 'admin.php?page=mailyard#/logs?status=failed' ); + $log_url = Settings::url( 'logs?status=failed' ); $dismiss_url = wp_nonce_url( add_query_arg( 'mailyard_dismiss_failure', '1' ), 'mailyard_dismiss_failure' @@ -109,11 +110,12 @@ public function render(): void { ? sprintf( _n( 'Mailyard: %d email failed to send', 'Mailyard: %d emails failed to send recently', $count, 'mailyard' ), $count ) : __( 'Mailyard: an email failed to send', 'mailyard' ); + // One dismiss path: the link (per user, until the next failure). No + // `is-dismissible` × — it only hides the notice until the next page load. printf( - '
', + '', esc_html( $heading . ( $title ? ': ' . $title : '' ) ), esc_html( $guidance ), - '', esc_url( $log_url ), esc_html__( 'View email log', 'mailyard' ), esc_url( $dismiss_url ), diff --git a/includes/class-oauth.php b/includes/class-oauth.php index b3779ff..3ef927a 100644 --- a/includes/class-oauth.php +++ b/includes/class-oauth.php @@ -141,7 +141,7 @@ public static function return_url( $result ): string { $args = is_wp_error( $result ) ? array( 'mailyard_oauth' => 'error', 'message' => $result->get_error_message() ) : array( 'mailyard_oauth' => 'ok' ); - return admin_url( 'admin.php?page=mailyard&' . http_build_query( $args, '', '&', PHP_QUERY_RFC3986 ) . '#/connections' ); + return add_query_arg( rawurlencode_deep( $args ), admin_url( 'options-general.php?page=' . Settings::PAGE ) ) . '#/connections'; } /** diff --git a/includes/class-plugin.php b/includes/class-plugin.php index 149735c..906e90f 100644 --- a/includes/class-plugin.php +++ b/includes/class-plugin.php @@ -130,7 +130,7 @@ public function run_cleanup(): void { } public function plugin_action_links( array $links ): array { - $url = admin_url( 'admin.php?page=mailyard' ); + $url = Settings::url(); $label = __( 'Settings', 'mailyard' ); array_unshift( $links, '' . $label . '' ); return $links; diff --git a/includes/class-settings.php b/includes/class-settings.php index 49fa7da..113d582 100644 --- a/includes/class-settings.php +++ b/includes/class-settings.php @@ -6,6 +6,11 @@ // Admin page and asset loading for the Mailyard React UI. class Settings { + const PAGE = 'mailyard'; + + // The page's screen id / hook suffix under Settings. + const SCREEN = 'settings_page_mailyard'; + private static $instance = null; public static function instance(): self { @@ -22,72 +27,58 @@ public function init() { add_action( 'admin_init', array( $this, 'redirect_legacy_url' ) ); } + /** + * The admin page URL, optionally at an app route ('logs', 'connections', …). + * Every link into the app goes through here. + */ + public static function url( string $route = '' ): string { + $url = admin_url( 'options-general.php?page=' . self::PAGE ); + return '' === $route ? $url : $url . '#/' . ltrim( $route, '#/' ); + } + /** * PlugPress design system token scope on , so portaled overlays * (dialogs, dropdowns, toasts) inherit the --pp-* custom properties. */ public function body_class( $classes ) { $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; - if ( $screen && 'toplevel_page_mailyard' === $screen->id ) { + if ( $screen && self::SCREEN === $screen->id ) { $classes .= ' pp-scope'; } return $classes; } + // Settings → SMTP: one page, the app's own top bar handles the sections. public function add_menu() { - // '58.14' is Mailyard's reserved slot in the PlugPress admin-menu band - // (plugpress-ui php/menu-positions.php) — a decimal STRING so it can - // never clobber a third-party menu registered at the same integer. - add_menu_page( - __( 'Mailyard', 'mailyard' ), - __( 'Mailyard', 'mailyard' ), + add_options_page( + __( 'Mailyard SMTP', 'mailyard' ), + __( 'SMTP', 'mailyard' ), 'manage_options', - 'mailyard', - array( $this, 'render' ), - $this->menu_icon(), - '58.14' - ); - - // One native submenu entry per page, mirroring the app's sidebar (NAV in - // src/App.jsx). Keys are the SPA hash route each entry lands on. - $submenus = array( - 'dashboard' => __( 'Dashboard', 'mailyard' ), - 'connections' => __( 'Connections', 'mailyard' ), - 'logs' => __( 'Email log', 'mailyard' ), - 'deliverability' => __( 'Deliverability', 'mailyard' ), - 'settings' => __( 'Settings', 'mailyard' ), + self::PAGE, + array( $this, 'render' ) ); - - $first = true; - foreach ( $submenus as $key => $label ) { - add_submenu_page( - 'mailyard', - $label, - $label, - 'manage_options', - $first ? 'mailyard' : 'mailyard#/' . $key, - array( $this, 'render' ) - ); - $first = false; - } } /** - * 302 the pre-1.2 Settings-page URL to the top-level menu. Browsers carry - * the URL fragment across the redirect, so old deep bookmarks - * (options-general.php?page=mailyard#/logs) keep working. + * 302 the old top-level menu URL (admin.php?page=mailyard) to Settings → + * SMTP, keeping its query args (an OAuth return carries its outcome there). + * Browsers carry the URL fragment across the redirect, so old deep links + * (…page=mailyard#/logs, in alert emails already sent) keep working. */ public function redirect_legacy_url() { global $pagenow; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only redirect. - if ( 'options-general.php' === $pagenow && 'mailyard' === sanitize_key( wp_unslash( $_GET['page'] ?? '' ) ) ) { - wp_safe_redirect( admin_url( 'admin.php?page=mailyard' ) ); - exit; + if ( 'admin.php' !== $pagenow || self::PAGE !== sanitize_key( wp_unslash( $_GET['page'] ?? '' ) ) ) { + return; } + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only redirect. + $args = map_deep( wp_unslash( $_GET ), 'sanitize_text_field' ); + wp_safe_redirect( add_query_arg( rawurlencode_deep( $args ), admin_url( 'options-general.php' ) ) ); + exit; } public function enqueue( $hook ) { - if ( 'toplevel_page_mailyard' !== $hook ) { + if ( self::SCREEN !== $hook ) { return; } @@ -128,43 +119,6 @@ private function locked_fields(): object { } public function render() { - ?> - - - ' - . '{ children }
+ { action &&