feat: Phase 4 - Mailwizz integration with subscriber sync and retry
This commit is contained in:
311
app/Services/MailwizzService.php
Normal file
311
app/Services/MailwizzService.php
Normal file
@@ -0,0 +1,311 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use RuntimeException;
|
||||
|
||||
final class MailwizzService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $apiKey,
|
||||
private readonly string $baseUrl = 'https://www.mailwizz.nl/api'
|
||||
) {}
|
||||
|
||||
private function client(): PendingRequest
|
||||
{
|
||||
return Http::withHeaders(['X-Api-Key' => $this->apiKey])->asForm();
|
||||
}
|
||||
|
||||
private function logInteraction(string $action, string $url, Response $response): void
|
||||
{
|
||||
Log::debug('Mailwizz API', [
|
||||
'action' => $action,
|
||||
'url' => $url,
|
||||
'http_status' => $response->status(),
|
||||
'response_preview' => mb_substr($response->body(), 0, 2000),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{list_uid: string, name: string}>
|
||||
*/
|
||||
public function getLists(): array
|
||||
{
|
||||
$url = "{$this->baseUrl}/lists";
|
||||
$response = $this->client()->get($url);
|
||||
$this->logInteraction('getLists', $url, $response);
|
||||
|
||||
if ($response->failed()) {
|
||||
throw new RuntimeException(__('Invalid API key or connection failed.'));
|
||||
}
|
||||
|
||||
$json = $response->json();
|
||||
if (! is_array($json)) {
|
||||
throw new RuntimeException(__('Invalid API key or connection failed.'));
|
||||
}
|
||||
|
||||
if (($json['status'] ?? null) === 'error') {
|
||||
throw new RuntimeException(__('Invalid API key or connection failed.'));
|
||||
}
|
||||
|
||||
return $this->normalizeListsPayload($json);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{tag: string, label: string, type_identifier: string, options: array<string, string>}>
|
||||
*/
|
||||
public function getListFields(string $listUid): array
|
||||
{
|
||||
$url = "{$this->baseUrl}/lists/{$listUid}/fields";
|
||||
$response = $this->client()->get($url);
|
||||
$this->logInteraction('getListFields', $url, $response);
|
||||
|
||||
if ($response->failed()) {
|
||||
throw new RuntimeException(__('Failed to fetch list fields.'));
|
||||
}
|
||||
|
||||
$json = $response->json();
|
||||
if (! is_array($json)) {
|
||||
throw new RuntimeException(__('Failed to fetch list fields.'));
|
||||
}
|
||||
|
||||
if (($json['status'] ?? null) === 'error') {
|
||||
throw new RuntimeException(__('Failed to fetch list fields.'));
|
||||
}
|
||||
|
||||
return $this->normalizeFieldsPayload($json);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search by email. Mailwizz uses query param EMAIL regardless of list field mapping.
|
||||
*
|
||||
* @return array{subscriber_uid: string}|null
|
||||
*/
|
||||
public function searchSubscriber(string $listUid, string $email): ?array
|
||||
{
|
||||
$url = "{$this->baseUrl}/lists/{$listUid}/subscribers/search-by-email";
|
||||
$response = $this->client()->get($url, [
|
||||
'EMAIL' => $email,
|
||||
]);
|
||||
$this->logInteraction('searchSubscriber', $url, $response);
|
||||
|
||||
if ($response->failed()) {
|
||||
if ($response->status() === 404) {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw new RuntimeException(__('Mailwizz subscriber search failed.'));
|
||||
}
|
||||
|
||||
$json = $response->json();
|
||||
if (! is_array($json)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (($json['status'] ?? null) !== 'success') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$uid = data_get($json, 'data.subscriber_uid')
|
||||
?? data_get($json, 'data.record.subscriber_uid');
|
||||
|
||||
if (! is_string($uid) || $uid === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ['subscriber_uid' => $uid];
|
||||
}
|
||||
|
||||
/**
|
||||
* Full subscriber payload from Mailwizz (structure varies; callers inspect data.record).
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function getSubscriber(string $listUid, string $subscriberUid): ?array
|
||||
{
|
||||
$url = "{$this->baseUrl}/lists/{$listUid}/subscribers/{$subscriberUid}";
|
||||
$response = $this->client()->get($url);
|
||||
$this->logInteraction('getSubscriber', $url, $response);
|
||||
|
||||
if ($response->failed()) {
|
||||
throw new RuntimeException(__('Mailwizz could not load subscriber.'));
|
||||
}
|
||||
|
||||
$json = $response->json();
|
||||
if (! is_array($json) || ($json['status'] ?? null) !== 'success') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $json;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data Form fields; checkboxlist values as string[] per field tag
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function createSubscriber(string $listUid, array $data): array
|
||||
{
|
||||
$url = "{$this->baseUrl}/lists/{$listUid}/subscribers";
|
||||
$response = $this->client()->post($url, $this->encodeFormPayload($data));
|
||||
$this->logInteraction('createSubscriber', $url, $response);
|
||||
|
||||
if ($response->failed()) {
|
||||
throw new RuntimeException(__('Mailwizz rejected subscriber creation.'));
|
||||
}
|
||||
|
||||
$json = $response->json();
|
||||
if (! is_array($json) || ($json['status'] ?? null) !== 'success') {
|
||||
$msg = is_array($json) ? (string) ($json['error'] ?? $json['message'] ?? '') : '';
|
||||
|
||||
throw new RuntimeException($msg !== '' ? $msg : __('Mailwizz rejected subscriber creation.'));
|
||||
}
|
||||
|
||||
return $json;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function updateSubscriber(string $listUid, string $subscriberUid, array $data): array
|
||||
{
|
||||
$url = "{$this->baseUrl}/lists/{$listUid}/subscribers/{$subscriberUid}";
|
||||
$response = $this->client()->put($url, $this->encodeFormPayload($data));
|
||||
$this->logInteraction('updateSubscriber', $url, $response);
|
||||
|
||||
if ($response->failed()) {
|
||||
throw new RuntimeException(__('Mailwizz rejected subscriber update.'));
|
||||
}
|
||||
|
||||
$json = $response->json();
|
||||
if (! is_array($json) || ($json['status'] ?? null) !== 'success') {
|
||||
$msg = is_array($json) ? (string) ($json['error'] ?? $json['message'] ?? '') : '';
|
||||
|
||||
throw new RuntimeException($msg !== '' ? $msg : __('Mailwizz rejected subscriber update.'));
|
||||
}
|
||||
|
||||
return $json;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function encodeFormPayload(array $data): array
|
||||
{
|
||||
$out = [];
|
||||
foreach ($data as $key => $value) {
|
||||
if (is_array($value)) {
|
||||
$out[$key] = $value;
|
||||
|
||||
continue;
|
||||
}
|
||||
if ($value === null) {
|
||||
continue;
|
||||
}
|
||||
$out[$key] = $value;
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{list_uid: string, name: string}>
|
||||
*/
|
||||
private function normalizeListsPayload(array $json): array
|
||||
{
|
||||
$out = [];
|
||||
$records = data_get($json, 'data.records');
|
||||
if (! is_array($records)) {
|
||||
return $out;
|
||||
}
|
||||
|
||||
foreach ($records as $row) {
|
||||
if (! is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$uid = data_get($row, 'general.list_uid') ?? data_get($row, 'list_uid');
|
||||
$name = data_get($row, 'general.name') ?? data_get($row, 'name');
|
||||
if (is_string($uid) && $uid !== '' && is_string($name) && $name !== '') {
|
||||
$out[] = ['list_uid' => $uid, 'name' => $name];
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{tag: string, label: string, type_identifier: string, options: array<string, string>}>
|
||||
*/
|
||||
private function normalizeFieldsPayload(array $json): array
|
||||
{
|
||||
$out = [];
|
||||
$records = data_get($json, 'data.records');
|
||||
if (! is_array($records)) {
|
||||
return $out;
|
||||
}
|
||||
|
||||
foreach ($records as $row) {
|
||||
if (! is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$tag = data_get($row, 'tag');
|
||||
$label = data_get($row, 'label');
|
||||
$typeId = data_get($row, 'type.identifier');
|
||||
if (! is_string($tag) || $tag === '' || ! is_string($label)) {
|
||||
continue;
|
||||
}
|
||||
$typeIdentifier = is_string($typeId) ? $typeId : '';
|
||||
$rawOptions = data_get($row, 'options');
|
||||
$options = $this->normalizeFieldOptions($rawOptions);
|
||||
|
||||
$out[] = [
|
||||
'tag' => $tag,
|
||||
'label' => $label,
|
||||
'type_identifier' => $typeIdentifier,
|
||||
'options' => $options,
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function normalizeFieldOptions(mixed $rawOptions): array
|
||||
{
|
||||
if ($rawOptions === null || $rawOptions === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (is_string($rawOptions)) {
|
||||
$decoded = json_decode($rawOptions, true);
|
||||
if (is_array($decoded)) {
|
||||
$rawOptions = $decoded;
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
if (! is_array($rawOptions)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ($rawOptions as $key => $value) {
|
||||
if (is_string($key) || is_int($key)) {
|
||||
$k = (string) $key;
|
||||
$out[$k] = is_scalar($value) ? (string) $value : '';
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user