Skip to content

Commit

Permalink
WIP
Browse files Browse the repository at this point in the history
  • Loading branch information
leeovery committed Feb 9, 2020
1 parent f730456 commit 5282037
Show file tree
Hide file tree
Showing 10 changed files with 251 additions and 25 deletions.
205 changes: 197 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,25 +1,214 @@
# Very short description of the package
# Mailcoach API

[![Latest Version on Packagist](https://img.shields.io/packagist/v/leeovery/mailcoach-api.svg?style=flat-square)](https://packagist.org/packages/leeovery/mailcoach-api)
[![Build Status](https://img.shields.io/travis/leeovery/mailcoach-api/master.svg?style=flat-square)](https://travis-ci.org/leeovery/mailcoach-api)
[![Quality Score](https://img.shields.io/scrutinizer/g/leeovery/mailcoach-api.svg?style=flat-square)](https://scrutinizer-ci.com/g/leeovery/mailcoach-api)
[![Total Downloads](https://img.shields.io/packagist/dt/leeovery/mailcoach-api.svg?style=flat-square)](https://packagist.org/packages/leeovery/mailcoach-api)
This is an opinionated API add-on to Spatie's [Mailcoach](https://mailcoach.app/) app.

This is where your description should go. Try and limit it to a paragraph or two, and maybe throw in a mention of what PSRs you support to avoid any confusion with users and contributors.
It provides a limited set of endpoints for managing Mailcoach over an API. It also provides some UI for managing your API clients for auth, and webhook management.

## Installation

To use this package you should first purchase and install Mailcoach from Spatie into a new or existing Laravel app. Alternatively you can install the Mailcoach standalone app.

Once that's done you can install this package alongside Mailcoach.

You can install the package via composer:

```bash
composer require leeovery/mailcoach-api
```

### Prepare Database:
Publish migrations...
``` bash
php artisan vendor:publish --tag=mailcoach-api-migrations
```
... & migrate.
``` bash
php artisan migrate
```

### Add Route Macro

Add the following line to your RouteServiceProvider `map` method:

```php
$apiPrefix = 'api';
$webPrefix = '';
Route::mailcoachApi($apiPrefix, $webPrefix);
```

The params passed in are the defaults so if you're happy with those then feel free to leave them out. On the other hand, you need to change the prefix, you can do so by changing what you pass into the macro.

### Publish Views:
``` bash
php artisan vendor:publish --tag=mailcoach-api-views
```

### Publish Config:
``` bash
php artisan vendor:publish --tag=mailcoach-api-config
```

In the config file that's published you can edit the middleware for both the web routes and the api routes.

You can also setup the webhook secret in this file. I recommend using an env var to set this up and keep it secret as it ensures your webhook endpoint(s) stay secure.

### Setup Auth

Setup Laravel Passport for machine-to-machine auth, as per the Passport instructions:
``` bash
php artisan passport:client --client
```

If you should now be able to see the new menu options in Mailcoach, namely `API Clients` & `Webhooks`.

To allow access using the API you will need to create yourself a new API Client. Once that's done you will then have a ClientID (which is an int) and a Client Secret. Both of these will be required to authenticate with the API.

To get actual access to the API from your client you will need to obtain an access_token. To do this, from the client app you will need to do a POST request to [your-api-url]/api/oauth/token with the following post body:

```php
grant_type:client_credentials
client_id:{your-client-id}
client_secret:{your-client-secret}
```

This should be obtained prior to each API session / request, as they don't last forever.

To get an idea you can run the following in your terminal to get the `access_token`:

```bash
curl -I -H ‘Content-Type: application/x-www-form-urlencoded’ -X POST ‘{your-api-url}/api/oauth/token' -d ‘grant_type=client_credentials&client_id={your-client-id}&client_secret={your-client-secret}’
```
## Usage
``` php
// Usage description here
### Consider...
This API makes a few assumptions which are important to understand.
The main one is that it considers a subscriber of a certain email, no matter which list they are on, to be the same person. Mailcoach has the concept that each `Subscriber` belongs to one `EmailLst`. A person can subscribe to multiple lists, but they will result in multiple `Subscriber` records being created for them.
This package introduces a `Contact` entity, which will group `Subscriber` records, based on the email, and will keep the email, names, and other meta info consolidated across all their `Subscriber` records.
#### Example:
Consider the user `[email protected]` is subscribed to 3 lists, and unsubscrbed from 1 list.
A `GET` request to `/contact/[email protected]` would result in the following response data:
```json5
{
"data": {
"email": "[email protected]",
"first_name": "Lee",
"last_name": "Overy",
"extra_attributes": {
"full_name": "Lee Overy"
},
"subscribed_to": [
{
"list_id": 1,
"subscription_id": 1,
"subscribed_at": "2020-02-09T12:31:40.000000Z"
},
{
"list_id": 2,
"subscription_id": 2,
"subscribed_at": "2020-02-09T12:31:40.000000Z"
}
],
"unsubscribed_from": [
{
"list_id": 3,
"subscription_id": 3,
"subscribed_at": "2020-02-09T12:31:40.000000Z",
"unsubscribed_at": "2020-02-09T12:31:57.000000Z"
}
]
}
}
```
The important thing to take away from this, is that if you want to keep `Subscriber` records of the **same email address** independent across `EmailLists`, you might need to fork this package and make some changes. Or do a PR and let's chat :)

### Endpoints
This package provides a limited number of endpoints, which you can easily see by running `php artisan route:list` from the app dir in your terminal.

But in a nutshell it provides:

#### GET /list
- Get all lists.
#### GET /list/{listId}
- Get a list by list primary ID.

#### GET /contact/{email}
- Get a contact
#### POST /contact
- Create a contact and subscribe to one or more lists
- FormRequest rules for reference:
```php
[
'email' => 'required|email:rfc,dns',
'list_ids' => 'required|array',
'list_ids.*' => ['required', 'integer', new Exists(EmailList::class, 'id')],
'attributes' => 'nullable|array',
'attributes.*' => 'string',
]
```
#### PATCH /contact
- Update a contacts name and/or email
- Add / remove extra meta info
- Add / remove from 1 or more lists
- Unsubscribe from all
- Resubscribe to all
- FormRequest rules for reference:
```php
[
'email' => 'sometimes|email:rfc,dns',
'unsubscribe_from_list_ids' => 'sometimes|array',
'unsubscribe_from_list_ids.*' => ['sometimes', 'integer', new Exists(EmailList::class, 'id')],
'resubscribe_to_list_ids' => 'sometimes|array',
'resubscribe_to_list_ids.*' => ['sometimes', 'integer', new Exists(EmailList::class, 'id')],
'attributes' => 'sometimes|nullable|array',
'attributes.*' => 'sometimes|nullable|string',
'unsubscribe_all' => 'sometimes|accepted|must_be_different:resubscribe_all',
'resubscribe_all' => 'sometimes|accepted|must_be_different:unsubscribe_all',
];
```
#### POST /campaign
- Create a new campaign with html content (recommend using a Mailable and calling `render` to produce the html)
- Can schedule to send in the future
- Omitting scheduled date will result in the campaign sending immdiately
- FormRequest rules for reference:
```php
[
'name' => ['required', 'alpha_dash', new Unique(Campaign::class, 'name')],
'subject' => 'required|string',
'content' => 'required|string',
'list_id' => ['required', 'integer', new Exists(EmailList::class, 'id')],
'from_email' => 'sometimes|email:rfc,dns',
'from_name' => 'sometimes|string',
'scheduled_at' => 'sometimes|nullable|date_format:Y-m-d H:i',
];
```

## Webhooks

We use the awesome [Laravel Webhook Server](https://github.com/spatie/laravel-webhook-server) for provide webhook support.

If you want to change some of the defaults, you can publish teh config file from that package and tweak away. This package simply uses the defaults so anything you change will take effect.

To receive the webhooks this package dispatches you should use the matching client package: [Laravel Webhook Client](https://github.com/spatie/laravel-webhook-client).

### Get Familiar:
Easiest way to get familiar with this is to check webhook the UI. Go ahead and create a new Webhook and you will see how it works.

Essentially, any time a Mailcoach event occurs, we will intercept it, check if any webhooks are configured for that event, and if so, go ahead and fire them.

This way your client app can be notified when a subscriber unsubscribes, or marks an email you send as spam etc.

On the whole, anything triggered via the API wont result in a webhook being fired. The exception to this is sending a campaign via the API - that will trigger events and therefore webhooks. However, updating contacts usually won't - unless I missed something. To be safe, it would be wise to code an awareness for the potential of feedback loops when using the API and webhooks.
### Final note:
If you're unsure of how this will work for you, the best thing to do is **check the code**.

### Testing

Expand Down
2 changes: 0 additions & 2 deletions config/mailcoach-api.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@

'webhooks' => [

'enabled' => env('MAILCOACH_API_WEBHOOK_ENABLED', false),

'secret' => env('MAILCOACH_API_WEBHOOK_SECRET', 'this-is-meant-to-be-a-secret-dude'),

],
Expand Down
1 change: 1 addition & 0 deletions database/create_mailcoach_api_tables.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ public function up()
$table->bigIncrements('id');
$table->unsignedBigInteger('webhook_id');
$table->string('url');
$table->string('event');
$table->string('status');
$table->json('payload');
$table->json('headers');
Expand Down
49 changes: 39 additions & 10 deletions resources/views/app/webhooks/events-log.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,26 +14,29 @@

@section('webhook')
@if($totalEventLogsCount > 0)
{{-- <div class="table-actions">--}}
{{-- <div class="table-filters">--}}
{{-- <x-search placeholder="Filter opens" />--}}
{{-- </div>--}}
{{-- </div>--}}
<div class="table-actions">
<div class="table-filters">
<x-search placeholder="Filter events"/>
</div>
</div>

<table class="table table-fixed">
<thead>
<tr>
<x-th sort-by="status" class="">On Event</x-th>
<x-th sort-by="email">Url</x-th>
<x-th sort-by="event" class="">On Event</x-th>
<th>Url</th>
<x-th sort-by="status" class="w-24">Status</x-th>
<x-th sort-by="attempts" class="w-32 th-numeric">Attempts</x-th>
<x-th sort-by="-created_at" sort-default class="w-48 th-numeric hidden | md:table-cell">Created At</x-th>
<x-th sort-by="-created_at" sort-default class="w-48 th-numeric hidden | md:table-cell">
Created At
</x-th>
<th class="w-12"></th>
</tr>
</thead>
<tbody>
@foreach($eventLogs as $eventLog)
<tr>
<td>{{ $eventLog->payload['event'] }}</td>
<td>{{ $eventLog->event }}</td>
<td class="markup-links">
<div class="break-words">
{{ $eventLog->url }}
Expand All @@ -47,11 +50,37 @@
<i class="fas fa-ban text-orange-500" title="{{ ucfirst($eventLog->status) }}"></i>
@endif
@if ($eventLog->isFinalFailure())
<i class="fas fa-skull-crossbones text-red-500" title="{{ ucfirst($eventLog->status) }}"></i>
<i class="fas fa-skull-crossbones text-red-500"
title="{{ ucfirst($eventLog->status) }}"></i>
@endif
</td>
<td class="td-numeric">{{ $eventLog->attempts }}</td>
<td class="td-numeric hidden | md:table-cell">{{ $eventLog->created_at->toMailcoachFormat() }}</td>
<td class="td-action">
<div class="dropdown" data-dropdown>
<button class="icon-button" data-dropdown-trigger>
<i class="fas fa-ellipsis-v | dropdown-trigger-rotate"></i>
</button>
<ul class="dropdown-list dropdown-list-left | hidden" data-dropdown-list>
<li>
<button data-modal-trigger="show-payload-{{ $eventLog->id }}">
<x-icon-label icon="fa-box" text="Show Payload"/>
</button>
<x-modal title="Event Payload" :name="'show-payload-'.$eventLog->id">
@include('mailcoach-api::app.webhooks.partials.json', ['json' => $eventLog->payload])
</x-modal>
</li>
<li>
<button data-modal-trigger="show-headers-{{ $eventLog->id }}">
<x-icon-label icon="fa-user-secret" text="Show Headers"/>
</button>
<x-modal title="Event Headers" :name="'show-headers-'.$eventLog->id">
@include('mailcoach-api::app.webhooks.partials.json', ['json' => $eventLog->headers])
</x-modal>
</li>
</ul>
</div>
</td>
</tr>
@endforeach
</tbody>
Expand Down
3 changes: 3 additions & 0 deletions resources/views/app/webhooks/partials/json.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<div class="w-full h-full py-4 px-10 text-gray-100 bg-gray-700 break-words">
@json($json, JSON_PRETTY_PRINT)
</div>
1 change: 1 addition & 0 deletions src/Actions/Webhook/FinalWebhookCallFailedAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ public function execute(Webhook $webhook, FinalWebhookCallFailedEvent $event)
$webhook->webhookEvents()->create([
'status' => WebhookEventLogStatus::FINAL_FAIL,
'url' => $event->webhookUrl,
'event' => $event->payload['event'] ?? '',
'payload' => $event->payload,
'headers' => $event->headers,
'attempts' => $event->attempt,
Expand Down
1 change: 1 addition & 0 deletions src/Actions/Webhook/WebhookCallFailedAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ public function execute(Webhook $webhook, WebhookCallFailedEvent $event)
$webhook->webhookEvents()->create([
'status' => WebhookEventLogStatus::FAILED,
'url' => $event->webhookUrl,
'event' => $event->payload['event'] ?? '',
'payload' => $event->payload,
'headers' => $event->headers,
'attempts' => $event->attempt,
Expand Down
1 change: 1 addition & 0 deletions src/Actions/Webhook/WebhookCallSucceededAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ public function execute(Webhook $webhook, WebhookCallSucceededEvent $event)
$webhook->webhookEvents()->create([
'status' => WebhookEventLogStatus::SUCCESS,
'url' => $event->webhookUrl,
'event' => $event->payload['event'] ?? '',
'payload' => $event->payload,
'headers' => $event->headers,
'attempts' => $event->attempt,
Expand Down
11 changes: 7 additions & 4 deletions src/Http/App/Queries/WebhookEventLogQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
namespace Leeovery\MailcoachApi\Http\App\Queries;

use Spatie\QueryBuilder\QueryBuilder;
use Spatie\QueryBuilder\AllowedFilter;
use Leeovery\MailcoachApi\Models\Webhook;
use Spatie\Mailcoach\Http\App\Queries\Filters\FuzzyFilter;

class WebhookEventLogQuery extends QueryBuilder
{
Expand All @@ -14,9 +16,10 @@ public function __construct(Webhook $webhook)
parent::__construct($query);

$this
->defaultSort('-created_at');
// ->allowedFilters(
// AllowedFilter::custom('search', new FuzzyFilter('name'))
// );
->defaultSort('-created_at')
->allowedSorts('event', 'status', 'attempts', 'created_at')
->allowedFilters(
AllowedFilter::custom('search', new FuzzyFilter('event')),
);
}
}
2 changes: 1 addition & 1 deletion src/Http/App/Queries/WebhookQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ class WebhookQuery extends QueryBuilder
{
public function __construct()
{
$query = Webhook::query();
$query = Webhook::query()->with('webhookEvents');

parent::__construct($query);

Expand Down

0 comments on commit 5282037

Please sign in to comment.