fix: send API params in POST body, remove double URL-encoding

Root causes identified from logs:
1. All params (login, password, cmd, email, etc.) were appended to the URL
   query string; phpList REST API v3 expects them in the POST body.
   wp_remote_post now sends body=>$params instead of an empty body.
2. subscriber_get_by_email and subscriber_add called rawurlencode() on the
   email before passing it to call(), which then ran http_build_query() on
   it again — double-encoding '@' to '%2540'. Both rawurlencode() calls
   removed; wp_remote_post handles POST body encoding correctly.

Additional improvements:
- endpoint_url() returns just the bare ?page=call&pi=restapi URL
- On API error, log full_response (entire JSON) not just the message field,
  so phpList's exact reply is always visible in WC Status Logs
- Non-JSON response now gives an explicit error message pointing to the
  endpoint URL / credentials rather than a generic JSON parse failure

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-20 08:18:43 +01:00
parent 7f1f351ff1
commit 1c382f2cf4

View File

@@ -2,6 +2,10 @@
/**
* phpList REST API wrapper.
*
* The phpList REST API v3 expects a POST request with ALL parameters
* (including login, password, cmd) in the POST body — NOT the query string.
* The endpoint URL itself only needs ?page=call&pi=restapi.
*
* @package WooList
*/
@@ -12,7 +16,7 @@ class WooList_API {
/**
* Retrieve a saved plugin option.
*
* @param string $key Option key (without woolist_ prefix).
* @param string $key Option key without the woolist_ prefix.
* @param mixed $default Default value.
* @return mixed
*/
@@ -21,55 +25,60 @@ class WooList_API {
}
/**
* Build the full phpList REST API URL.
* Return the base API endpoint URL (no credentials, no command).
*
* All parameters are passed as query-string arguments because the phpList
* REST API reads them from $_GET regardless of the HTTP method used.
*
* @param string $cmd phpList API command.
* @param array $extra Additional query parameters.
* @return string|WP_Error Full URL or WP_Error when base URL is missing.
* @return string|WP_Error
*/
public function build_url( string $cmd, array $extra = [] ) {
$base = $this->get_option( 'phplist_url' );
$base = rtrim( $base, '/' );
private function endpoint_url() {
$base = rtrim( (string) $this->get_option( 'phplist_url' ), '/' );
if ( empty( $base ) ) {
return new WP_Error( 'woolist_no_url', __( 'phpList base URL is not configured.', 'woolist-phplist' ) );
}
$params = array_merge(
return $base . '/admin/?page=call&pi=restapi';
}
/**
* Execute a phpList REST API call.
*
* Credentials and all command parameters are sent in the POST body so
* phpList can authenticate and parse the request correctly.
*
* @param string $cmd phpList API command (e.g. subscriberAdd).
* @param array $extra Additional POST body parameters.
* @return array|WP_Error Decoded JSON data or WP_Error.
*/
public function call( string $cmd, array $extra = [] ) {
$url = $this->endpoint_url();
if ( is_wp_error( $url ) ) {
WooList_Logger::error( 'Cannot build endpoint URL for cmd=' . $cmd . ': ' . $url->get_error_message() );
return $url;
}
// Build the POST body — credentials + command + any extra params.
$body = array_merge(
[
'page' => 'call',
'pi' => 'restapi',
'login' => $this->get_option( 'phplist_login' ),
'password' => $this->get_option( 'phplist_password' ),
'login' => (string) $this->get_option( 'phplist_login' ),
'password' => (string) $this->get_option( 'phplist_password' ),
'cmd' => $cmd,
],
$extra
);
return $base . '/admin/?' . http_build_query( $params );
}
// Log the outgoing request at debug level; redact the password.
$logged_body = $body;
$logged_body['password'] = '***';
WooList_Logger::debug( '→ API request cmd=' . $cmd . ' endpoint=' . $url . ' body=' . wp_json_encode( $logged_body ) );
/**
* Execute an API call and return decoded JSON data.
*
* @param string $cmd phpList API command.
* @param array $extra Additional query parameters.
* @return array|WP_Error Decoded response data or WP_Error.
*/
public function call( string $cmd, array $extra = [] ) {
$url = $this->build_url( $cmd, $extra );
if ( is_wp_error( $url ) ) {
WooList_Logger::error( 'Cannot build URL for cmd=' . $cmd . ': ' . $url->get_error_message() );
return $url;
}
WooList_Logger::debug( '→ API request cmd=' . $cmd . ' url=' . WooList_Logger::redact_url( $url ) );
$response = wp_remote_post( $url, [ 'timeout' => 15 ] );
$response = wp_remote_post(
$url,
[
'timeout' => 15,
'body' => $body,
]
);
if ( is_wp_error( $response ) ) {
WooList_Logger::error( 'HTTP request failed cmd=' . $cmd . ' error=' . $response->get_error_message() );
@@ -77,31 +86,33 @@ class WooList_API {
}
$code = wp_remote_retrieve_response_code( $response );
$body = wp_remote_retrieve_body( $response );
$body_raw = wp_remote_retrieve_body( $response );
// Log the raw response at debug level (truncated to 2 KB to avoid huge entries).
WooList_Logger::debug( '← API response cmd=' . $cmd . ' http=' . $code . ' body=' . substr( $body, 0, 2048 ) );
// Always log the raw response at DEBUG so problems are immediately visible.
WooList_Logger::debug( '← API response cmd=' . $cmd . ' http=' . $code . ' body=' . substr( $body_raw, 0, 2048 ) );
if ( $code < 200 || $code >= 300 ) {
WooList_Logger::error( 'API returned HTTP ' . $code . ' cmd=' . $cmd );
WooList_Logger::error( 'Non-2xx HTTP response cmd=' . $cmd . ' http=' . $code . ' body=' . substr( $body_raw, 0, 500 ) );
return new WP_Error( 'woolist_http_error', 'HTTP error ' . $code );
}
$data = json_decode( $body, true );
$data = json_decode( $body_raw, true );
if ( json_last_error() !== JSON_ERROR_NONE ) {
WooList_Logger::error( 'Invalid JSON response cmd=' . $cmd . ' body=' . substr( $body, 0, 500 ) );
return new WP_Error( 'woolist_json_error', 'Invalid JSON response from phpList.' );
// Not JSON — likely an HTML error page or wrong endpoint.
WooList_Logger::error( 'Non-JSON response cmd=' . $cmd . ' body=' . substr( $body_raw, 0, 500 ) );
return new WP_Error( 'woolist_json_error', 'phpList did not return JSON. Check endpoint URL and credentials.' );
}
// phpList REST API signals errors via a "status" field.
// phpList signals errors via status=error; message may be in several fields.
if ( isset( $data['status'] ) && strtolower( $data['status'] ) === 'error' ) {
$message = $data['errormessage'] ?? $data['message'] ?? 'Unknown API error';
WooList_Logger::error( 'API error cmd=' . $cmd . ' message=' . $message );
return new WP_Error( 'woolist_api_error', $message );
$message = $data['errormessage'] ?? $data['message'] ?? $data['data'] ?? 'Unknown API error';
// Also dump the full response so the admin can see exactly what phpList said.
WooList_Logger::error( 'API error cmd=' . $cmd . ' message=' . $message . ' full_response=' . wp_json_encode( $data ) );
return new WP_Error( 'woolist_api_error', (string) $message );
}
WooList_Logger::debug( 'API call succeeded cmd=' . $cmd );
WooList_Logger::debug( 'API call succeeded cmd=' . $cmd . ' response=' . wp_json_encode( $data ) );
return $data;
}
@@ -112,7 +123,8 @@ class WooList_API {
* @return array|WP_Error
*/
public function subscriber_get_by_email( string $email ) {
return $this->call( 'subscriberGetByEmail', [ 'email' => rawurlencode( $email ) ] );
// No manual encoding — wp_remote_post encodes POST body values correctly.
return $this->call( 'subscriberGetByEmail', [ 'email' => $email ] );
}
/**
@@ -125,7 +137,7 @@ class WooList_API {
return $this->call(
'subscriberAdd',
[
'email' => rawurlencode( $email ),
'email' => $email,
'confirmed' => 1,
'htmlemail' => 1,
]
@@ -150,9 +162,11 @@ class WooList_API {
}
/**
* High-level helper: subscribe an email to a list.
* Subscribe an email address to a phpList list.
*
* Gets or creates the subscriber, then adds them to the list.
* Looks up the subscriber first; creates one if not found; then adds
* them to the target list. Returns a result array so callers can check
* success without inspecting WP_Error internals.
*
* @param string $email Subscriber email address.
* @param int $list_id phpList list ID.