Skip to content

feat: simple SMTP — drop Pro, Meow Mailer parity (#17) - #18

Merged
ifahimreza merged 16 commits into
mainfrom
feat/17-simple-smtp
Oct 6, 2026
Merged

ifahimreza merged 16 commits into
mainfrom
feat/17-simple-smtp

Conversation

@ifahimreza

@ifahimreza ifahimreza commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Closes #17

What

Mailyard becomes a clean, standalone SMTP plugin at feature parity with Meow Mailer, keeping what Mailyard already does better (multi-provider failover, sender routing, bounce webhooks, deliverability checker, MCP delivery tools). Built in steps; see the checklist in #17.

Landed

  1. Pro extension layer + Marketing connection purpose removed; admin flattened to Dashboard · Connections · Email log · Deliverability · Settings (mirrored in the WP submenu).
  2. Send pipeline: wp_mail() parsed once (Message) — Cc/Bcc/Reply-To from headers (were silently dropped for every provider), custom headers + attachment filenames pass through, one message to all recipients. Offline mode, Background sending (pending → settled after fastcgi_finish_request), PHP Mail as a real connection (zero-setup backup), Send test names the delivering provider and any backup takeover.
  3. Email log: provider filter, paging, CSV export (formula-safe), retention 7/30/90/forever, resend any row, dashboard "Why emails failed" panel.
  4. Alerts: failure + "backup took over" email (sent unrouted via WordPress's own mailer, max 1/hour each), Slack / Discord / Teams (Adaptive Card) / generic JSON webhook with test, weekly summary (skipped on quiet weeks).
  5. WordPress email switches (password resets never switchable) + Return path (mailyard_return_path filter).
  6. One-click importer: WP Mail SMTP, Easy WP SMTP (sodium-encrypted SMTP password opened), FluentSMTP (all connections, default+fallback order, per-sender mappings, AES-CTR secrets), Post SMTP (base64).
  7. Credentials as wp-config constants (MAILYARD_{PROVIDER}_{FIELD}), optional at-rest encryption (sodium; "Locked" state if salts rotate), settings export/import, empty log.
  8. Six API providers: Mailgun, SendGrid, SMTP2GO, Mailjet, MailerSend, Maileroo.
  9. OAuth: Gmail / Google Workspace, Microsoft 365 (delegated, via Graph JSON so any From works) + Microsoft 365 app-only (client secret or certificate, no sign-in), Zoho Mail. Tokens never reach the browser (REST returns a connected flag).
  10. Multisite: the main site shares its connections (sender and delivery options each optional); subsites read-only for shared parts; logs per site.
  11. Dashboard setup checks: wp_mail() replaced by another plugin, From on another domain, CF7/WPForms/Gravity forms sending "from" the visitor.

All 11 checklist items of #17 are in.

Why

User decision (2026-10-06): no Pro add-on, SMTP only, simple and modern like Meow Mailer.

How

  • New classes, each with a stated job: Message (wp_mail parsing), Importer, Alerts, WP_Emails, Crypto, Checks; six ESP drivers.
  • Connection storage goes through Options::connections() / save_connections() (encryption choke point); Manager clones a driver per connection (fixes two same-provider connections sharing credentials during failover).
  • Plugin::retire_marketing_purpose() (idempotent): Marketing-only connections are disabled once, purpose keys stripped.
  • Also fixed: SES MIME parts base64 (UTF-8 / long lines), SMTP "None" encryption no longer auto-STARTTLS, SES region notice.
  • This branch carries the previously unpushed local release: 1.0.2 commit (684f536). Changelog is written against 1.1.0; the Version: header is not bumped (needs your OK).
  • src/hooks/useLogs.js is no longer imported (the log page fetches directly); left in place pending removal.

Breaking

Public hooks removed (only Mailyard Pro consumed them):

  • PHP: mailyard_admin_submenus, mailyard_admin_enqueue, mailyard_purpose, MAILYARD_SHELL_VERSION.
  • JS (wp.hooks): mailyard.shell.modules, .dashboardWidgets, .settingsSections, .dataPanels, .aiSections.
  • X-Mailyard-Purpose header ignored. An email to several To addresses is now one message (as core wp_mail sends it), not one per address.

New public hooks: mailyard_send_rescued action, mailyard_return_path filter.

Testing

  • npm run build compiles (2 pre-existing size warnings); php -l clean
  • Throwaway suites run against WP 7.1.2 / PHP 8.5 on the dev site (fake providers, filtered options, captured HTTP/mail, test rows cleaned up): send pipeline 24, provider payloads 10 + 36, OAuth 66, importer 15, WP emails 7, alerts 15, log 5, security/backup 15, checks 10 — all pass
  • Multisite: 14 checks in a WordPress Playground multisite (the later subsite token-write path wasn't re-run: Playground CDN started returning 403)
  • Visual QA of every admin page in a WordPress Playground (WP latest, PHP 8.3) — renders clean; found and fixed an Alerts auto-save loop
  • Live OAuth consent + real sends per provider (not possible here)
  • Tested with the plugin's known conflict list
  • No PHP notices/warnings with WP_DEBUG on (in a live admin session)
  • Free vs pro gating — n/a, Pro removed
  • CI: Plugin Check fails only on Tested up to: 7.0 < 7.1 (WP 7.1 shipped; fails on main too) — bump needs your OK

Screenshots

Retake the .wordpress.org screenshots after merge (6 captions in readme).

🤖 Generated with Claude Code

ifahimreza and others added 15 commits October 6, 2026 04:37
- Manager::connection_config() centralizes provider-specific config
  derivation (Postmark stream from the connection's purpose); both the
  failover chain and the Test endpoint connect through it, so a
  Marketing connection now tests on the broadcast stream instead of
  silently testing transactional.
- readme rewritten the simple way (Meow Mailer register): title is just
  "Mailyard", first-person description, tags smtp/email log/mailer/
  email/deliverability, changelog in dated Add:/Fix: format with the
  1.0.0 feature list restored.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011WAxszR5QVMYFxWnj3XRT8
Mailyard is now a standalone SMTP plugin: no add-on shell, no
campaign routing.

- Admin: remove the shell module registry and the dashboard, settings,
  data and AI extension filters; App.jsx routes the five pages directly.
  The WP submenu mirrors the sidebar one-to-one.
- PHP: remove the mailyard_admin_submenus filter, the
  mailyard_admin_enqueue action and MAILYARD_SHELL_VERSION.
- Routing: connection purpose and the X-Mailyard-Purpose header are
  gone (mailyard_purpose filter removed); Postmark sends on the
  connection's own stream, default outbound. Marketing-only connections
  are switched off once on upgrade so they don't start carrying
  transactional mail.
- Copy: Pro mentions removed from readme, abilities and settings.

Refs #17

Co-Authored-By: Claude Opus 5.5 <[email protected]>
wp_mail() arguments are now parsed once (Message::from_wp_mail) into
the shape every driver sends:

- Cc/Bcc/Reply-To come from the headers. pre_wp_mail never passes a
  `cc` key, so they were silently dropped for every provider.
- Custom headers and caller-chosen attachment filenames pass through.
- One message goes to all recipients, as wp_mail() does, instead of a
  copy per address.

Override gains Offline mode (log, never send) and Background sending
(log as pending, send on shutdown after fastcgi_finish_request, then
settle the same row). Send test and Resend force a synchronous send
and report which provider delivered, including a backup takeover.

Also fixed along the way:
- Manager clones each driver per connection; two connections on one
  provider used to share credentials during failover.
- SES MIME parts are base64 (UTF-8, long minified lines).
- SMTP encryption "none" disables PHPMailer's auto-STARTTLS.
- PHP Mail is a real connection type: the UI tile used an id the REST
  API rejected. SMTP now extends it rather than duplicating the
  wp_mail() plumbing.

Refs #17

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Importer reads WP Mail SMTP, Easy WP SMTP, FluentSMTP and Post SMTP
setups server-side and appends them as Mailyard connections. Secrets
never pass through the browser.

- WP Mail SMTP / Easy WP SMTP: wp-config constants when {PREFIX}ON is
  set; the SMTP password is opened with their sodium secretbox key. A
  password that won't open comes over empty, never as ciphertext.
- FluentSMTP: every connection, default first and fallback second,
  per-sender mappings become from_match routing; AES-256-CTR secrets
  are decrypted and checked against the salt suffix.
- Post SMTP: base64-encoded secrets decoded.
- A source is offered only when it maps to a registered provider and
  its required credentials are readable. Imports go live only when
  Mailyard has no enabled connection; otherwise they arrive switched
  off. Imported sources are remembered and not offered again.

Connection storage and the primary-to-settings sync moved from
REST_API into Options so the importer shares them.

Refs #17

Co-Authored-By: Claude Opus 5.5 <[email protected]>
… Maileroo

Six API drivers on the same contract as Resend: recipients from
Recipients::split(), Cc/Bcc, Reply-To, custom headers, in-memory
attachments, and a plain-sentence error pulled from each API's JSON.

- Mailgun: multipart form built by hand (repeated to/cc/bcc, h:
  headers, raw attachment parts), US/EU region, bare "Forbidden" on a
  bad key handled.
- SendGrid: to/cc/bcc deduplicated (the API rejects repeats).
- SMTP2GO: Reply-To travels in custom_headers; a 200 with failures is
  a failure.
- Maileroo: Bearer sending key, { address, display_name } shape, subject
  trimmed to 255, and a 200 with success:false is a failure.
- MailerSend and Maileroo skip custom headers: MailerSend gates the
  field to paid plans and rejects the email otherwise; Maileroo v2
  documents no field for them.

Registered in Plugin, Manager, Options (all API providers, so no local
MX check), the provider picker and icons. Errors::humanize learns the
new unverified-sender, sandbox and auth phrasings. Readme lists the
providers and their external-service endpoints and legal links.

Refs #17

Co-Authored-By: Claude Opus 5.5 <[email protected]>
- WP_Emails: off switches for the notifications core sends by itself
  (new-user admin/welcome, password-reset admin notice, password and
  email change, new comment, moderation, core/plugin/theme auto-update
  reports). Password reset emails are deliberately not switchable.
- Return path: Settings > Delivery sets PHPMailer's envelope sender
  for SMTP, PHP Mail and WordPress's own sends; mailyard_return_path
  filters it per message.
- Plugin Check: annotate the SES API host (a newer Plugin Check reads
  amazonaws.com as offloaded assets) and the deliberate use of core's
  wp_mail_content_type filter.

Refs #17

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Alerts (Settings > Alerts):
- Failure alert by email, sent through WordPress's own mailer via
  Override::unrouted() so it doesn't travel through the provider that
  just failed, and isn't logged (no alert loops).
- "Backup took over" alert on the new mailyard_send_rescued action, so
  a broken primary doesn't hide behind "sent" rows.
- At most one of each per hour, claimed with a transient before
  sending.
- Webhook: Slack {text}, Discord {content}, Teams (Power Automate) an
  Adaptive Card, anything else a plain JSON object. Non-blocking for
  real alerts; the test waits, reports, and uses the URL as typed.
  Individual emails are never posted.
- Weekly summary on a weekly cron (cleared on deactivate or switch-off):
  counts plus top errors, routed like ordinary mail, skipped on a
  quiet week.

Logger gains count_failed_since() and top_errors(). ToggleRow moves to
a shared component.

Refs #17

Co-Authored-By: Claude Opus 5.5 <[email protected]>
- Logger::query filters by provider; export_csv() writes the filtered
  log (newest first, up to 10k rows, no bodies), with cells that start
  with = + - @ prefixed so a spreadsheet won't run them as formulas.
- GET logs/export returns the CSV; the browser saves it as a file.
- Email log page: provider select, search with a short debounce,
  25 per page with Previous/Next, Export CSV, and Resend on any
  non-pending row.
- Log retention setting: 7 / 30 (default) / 90 days, or 0 = forever.
- Dashboard: "Why emails failed · 7 days" from Logger::top_errors(),
  humanized through Errors.
- MCP list-logs accepts the pending and offline statuses.

The page now fetches through get() directly; src/hooks/useLogs.js is
no longer imported. It's left in place for now (see PR notes).

Refs #17

Co-Authored-By: Claude Opus 5.5 <[email protected]>
- Constants: any provider field can be set as
  MAILYARD_{PROVIDER}_{FIELD} (MAILYARD_SMTP_PASSWORD,
  MAILYARD_POSTMARK_API_KEY…). Manager::connection_config() applies
  them over stored values; GET connections reports them as `locked`.
- Encryption (Settings > Security): Crypto seals secret config fields
  with sodium secretbox, keyed from MAILYARD_ENCRYPTION_KEY or the
  site's salts. It happens at the one storage choke point:
  Options::save_connections() seals, Options::connections() opens.
  Manager now reads through Options::connections() too. Toggling
  re-saves every connection immediately. A secret that won't open
  (keys rotated) reads as '' and is flagged; the dashboard shows a
  "Locked" banner and Security explains the fix.
- Backup (Settings > Data & danger): export settings + connections
  (credentials included) as JSON; import restores through the same
  settings whitelist and rebuilds connections field by field. "Empty
  log" clears the log alone, with a confirm step.

Refs #17

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Checks::run() feeds a list of setup problems to the dashboard — things
that quietly break or weaken delivery even when sends "succeed":

- wp_mail() defined outside wp-includes/pluggable.php (another plugin
  took over; Mailyard's filters never run). Names the owner.
- Default From address on a different domain than the site
  (subdomains either way are fine).
- Contact Form 7 / WPForms / Gravity Forms set to send "from" the
  visitor's address. CF7 only counts a tag in the address part, so
  the stock "[_site_title] <wordpress@…>" sender isn't flagged.

Separate from Conflicts (other SMTP plugins).

Refs #17

Co-Authored-By: Claude Opus 5.5 <[email protected]>
A network admin, on the main site (Settings > Network), can share its
connections with every site. Sharing the sender (From email/name,
Return path) and the delivery options (background, logging,
retention) is each optional. Logs always stay per site.

- Options::settings() lays the shared groups over the subsite's own;
  Options::connections() reads the main site's list. Both go through
  shared_source(), which is 0 outside multisite, on the main site, or
  with sharing off.
- Subsites can't edit what's shared: connection writes, the importer
  and settings import return managed_by_network (403); save_settings
  skips shared keys; test results aren't written back.
- The Delivery screen on a subsite says it's managed by the main site
  and greys out the shared groups.

Verified in a WordPress Playground multisite (14 checks); the
single-site suites still pass.

Refs #17

Co-Authored-By: Claude Opus 5.5 <[email protected]>
No password stored. The admin creates an OAuth app, pastes its
credentials, saves, and clicks Connect account.

- OAuth (class-oauth.php): authorize links with a state binding a
  nonce to the connection; the admin-post callback (thin wrapper around
  complete()) trades the code for tokens and keeps them on that
  connection; access_token() refreshes within 60s of expiry and saves
  back, including Microsoft's rotated refresh tokens. The admin lands on
  admin.php?page=mailyard&mailyard_oauth=ok|error#/connections, since
  the hash router owns #/...
- Gmail: MIME via WordPress's PHPMailer (Mime::build), sent as
  base64url `raw` to users.messages.send.
- Microsoft 365 (delegated) and app-only: Graph JSON, not MIME. Graph
  then sends as the mailbox; a MIME From it doesn't own fails with
  ErrorSendAsDenied. Only X- headers pass, since Graph rejects others.
  App-only uses client credentials with a secret or a certificate-signed
  JWT (RS256, x5t thumbprint), the token cached in a transient keyed to
  the credential, and adds Entra hints on 401/403/404.
- Zoho Mail: data-center hosts; account id and verified addresses
  learned at sign-in; attachments uploaded first; an unverified Reply-To
  is dropped and an unowned From is sent as the account's address
  rather than failing.
- REST: tokens never reach the browser (stripped, `connected` flag
  instead); saves keep stored tokens unless client id, tenant or data
  center changed; POST connections/{id}/authorize; PEM keeps its line
  breaks.
- Importer: WP Mail SMTP / Easy WP SMTP gmail, outlook and zoho,
  FluentSMTP gmail and outlook, and Post SMTP gmail_api bring app
  credentials only (tokens are bound to the other plugin's redirect
  URI), so those imports arrive disabled, flagged to connect.
- UI: setup steps, copyable redirect URI, Save & connect account,
  Account card with Reconnect, "Not connected" in the list, and a
  toast on return.

Refs #17

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…ts save loop

- Connection editor: fields set in wp-config.php (MAILYARD_{PROVIDER}_
  {FIELD}) render disabled with the constant's name and aren't
  required; the page gets the provider => locked-keys map from
  Settings::locked_fields().
- Multisite: Options::save_connections() on a subsite under shared
  settings writes to the main site's option (with its encryption
  setting), so an OAuth token refreshed mid-send updates the shared
  connection instead of forking a stale local copy.
- Alerts settings: auto-save fires on user edits only (change counter,
  as in Delivery). Keying the save on the form object re-saved after
  every hydration. Verified in a Playground: one toggle, one POST.
- Merge of the OAuth work with wp-config constants: connection_config()
  applies constants and passes _connection_id; the public connection
  shape carries `connected` and `locked`.

Refs #17

Co-Authored-By: Claude Opus 5.5 <[email protected]>
"What it does" now covers the log's search/export/retention, alerts,
WordPress email switches, background and offline modes, multisite,
credential storage and the importer. Updated the migration and log
retention answers. Added an External services entry for the chat
webhook: what is posted (site, counts, error text — never contents
or recipients) and only when a URL is set.

Refs #17

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@ifahimreza
ifahimreza marked this pull request as ready for review October 5, 2026 23:49
Ran the plugin's suites against WP 7.1.2 this cycle; user approved
the bump (2026-10-06). Clears the only Plugin Check error.

Refs #17

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@ifahimreza
ifahimreza merged commit c0d9d22 into main Oct 6, 2026
2 checks passed
@ifahimreza
ifahimreza deleted the feat/17-simple-smtp branch October 6, 2026 16:38
ifahimreza added a commit that referenced this pull request Oct 6, 2026
Refs #17

Co-authored-by: Claude Opus 5.5 <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Simple SMTP: drop Pro, reach Meow Mailer feature parity

1 participant