Browse Source

Added basic rules system

Will 5 years ago
parent
commit
0fcf355bab

+ 5 - 1
SELF-HOSTING.md

@@ -455,14 +455,18 @@ Set a couple of variables:
 
 
 ```bash
 ```bash
 MODULE_NAME="mailparse"
 MODULE_NAME="mailparse"
-MODULE_VERSION="3.0.4"
+MODULE_VERSION="3.1.0"
 ```
 ```
 
 
 ```bash
 ```bash
 cd ~
 cd ~
 
 
+# download using pecl
 pecl download $MODULE_NAME
 pecl download $MODULE_NAME
 
 
+# or you can use wget if pecl is not found
+wget https://pecl.php.net/get/$MODULE_NAME-$MODULE_VERSION.tgz
+
 tar -zxvf $MODULE_NAME-$MODULE_VERSION.tgz
 tar -zxvf $MODULE_NAME-$MODULE_VERSION.tgz
 
 
 cd $MODULE_NAME-$MODULE_VERSION
 cd $MODULE_NAME-$MODULE_VERSION

+ 1 - 3
app/Console/Commands/ReceiveEmail.php

@@ -74,9 +74,7 @@ class ReceiveEmail extends Command
 
 
             $this->size = $this->option('size') / ($recipientCount ? $recipientCount : 1);
             $this->size = $this->option('size') / ($recipientCount ? $recipientCount : 1);
 
 
-            foreach ($recipients as $key => $recipient) {
-                $displayTo = $this->parser->getAddresses('to')[$key]['display'] ?? null;
-
+            foreach ($recipients as $recipient) {
                 $parentDomain = collect(config('anonaddy.all_domains'))
                 $parentDomain = collect(config('anonaddy.all_domains'))
                     ->filter(function ($name) use ($recipient) {
                     ->filter(function ($name) use ($recipient) {
                         return Str::endsWith($recipient['domain'], $name);
                         return Str::endsWith($recipient['domain'], $name);

+ 28 - 0
app/Http/Controllers/Api/ActiveRuleController.php

@@ -0,0 +1,28 @@
+<?php
+
+namespace App\Http\Controllers\Api;
+
+use App\Http\Controllers\Controller;
+use App\Http\Resources\RuleResource;
+use Illuminate\Http\Request;
+
+class ActiveRuleController extends Controller
+{
+    public function store(Request $request)
+    {
+        $rule = user()->rules()->findOrFail($request->id);
+
+        $rule->activate();
+
+        return new RuleResource($rule);
+    }
+
+    public function destroy($id)
+    {
+        $rule = user()->rules()->findOrFail($id);
+
+        $rule->deactivate();
+
+        return response('', 204);
+    }
+}

+ 23 - 0
app/Http/Controllers/Api/ReorderRuleController.php

@@ -0,0 +1,23 @@
+<?php
+
+namespace App\Http\Controllers\Api;
+
+use App\Http\Controllers\Controller;
+use App\Http\Requests\StoreReorderRuleRequest;
+use App\Rule;
+
+class ReorderRuleController extends Controller
+{
+    public function store(StoreReorderRuleRequest $request)
+    {
+        collect($request->ids)->each(function ($id, $key) {
+            $rule = Rule::findOrFail($id);
+
+            $rule->update([
+                'order' => $key
+            ]);
+        });
+
+        return response('', 200);
+    }
+}

+ 57 - 0
app/Http/Controllers/Api/RuleController.php

@@ -0,0 +1,57 @@
+<?php
+
+namespace App\Http\Controllers\Api;
+
+use App\Http\Controllers\Controller;
+use App\Http\Requests\StoreRuleRequest;
+use App\Http\Resources\RuleResource;
+
+class RuleController extends Controller
+{
+    public function index()
+    {
+        return RuleResource::collection(user()->rules()->orderBy('order')->get());
+    }
+
+    public function show($id)
+    {
+        $rule = user()->rules()->findOrFail($id);
+
+        return new RuleResource($rule);
+    }
+
+    public function store(StoreRuleRequest $request)
+    {
+        $rule = user()->rules()->create([
+            'name' => $request->name,
+            'conditions' => $request->conditions,
+            'actions' => $request->actions,
+            'operator' => $request->operator
+        ]);
+
+        return new RuleResource($rule->refresh());
+    }
+
+    public function update(StoreRuleRequest $request, $id)
+    {
+        $rule = user()->rules()->findOrFail($id);
+
+        $rule->update([
+            'name' => $request->name,
+            'conditions' => $request->conditions,
+            'actions' => $request->actions,
+            'operator' => $request->operator
+        ]);
+
+        return new RuleResource($rule->refresh());
+    }
+
+    public function destroy($id)
+    {
+        $rule = user()->rules()->findOrFail($id);
+
+        $rule->delete();
+
+        return response('', 204);
+    }
+}

+ 13 - 0
app/Http/Controllers/ShowRuleController.php

@@ -0,0 +1,13 @@
+<?php
+
+namespace App\Http\Controllers;
+
+class ShowRuleController extends Controller
+{
+    public function index()
+    {
+        return view('rules.index', [
+            'rules' => user()->rules()->orderBy('order')->get()
+        ]);
+    }
+}

+ 35 - 0
app/Http/Requests/StoreReorderRuleRequest.php

@@ -0,0 +1,35 @@
+<?php
+
+namespace App\Http\Requests;
+
+use App\Rules\ValidRuleId;
+use Illuminate\Foundation\Http\FormRequest;
+
+class StoreReorderRuleRequest extends FormRequest
+{
+    /**
+     * Determine if the user is authorized to make this request.
+     *
+     * @return bool
+     */
+    public function authorize()
+    {
+        return true;
+    }
+
+    /**
+     * Get the validation rules that apply to the request.
+     *
+     * @return array
+     */
+    public function rules()
+    {
+        return [
+            'ids' => [
+                'required',
+                'array',
+                new ValidRuleId
+            ]
+        ];
+    }
+}

+ 95 - 0
app/Http/Requests/StoreRuleRequest.php

@@ -0,0 +1,95 @@
+<?php
+
+namespace App\Http\Requests;
+
+use Illuminate\Foundation\Http\FormRequest;
+use Illuminate\Validation\Rule;
+
+class StoreRuleRequest extends FormRequest
+{
+    /**
+     * Determine if the user is authorized to make this request.
+     *
+     * @return bool
+     */
+    public function authorize()
+    {
+        return true;
+    }
+
+    /**
+     * Get the validation rules that apply to the request.
+     *
+     * @return array
+     */
+    public function rules()
+    {
+        return [
+            'name' => [
+                'required',
+                'string',
+                'max:50'
+            ],
+            'conditions' => [
+                'required',
+                'array',
+                'max:5'
+            ],
+            'conditions.*.type' => [
+                'required',
+                Rule::in([
+                    'subject',
+                    'sender',
+                    'alias'
+                ])
+            ],
+            'conditions.*.match' => [
+                'sometimes',
+                'required',
+                Rule::in([
+                    'is exactly',
+                    'is not',
+                    'contains',
+                    'does not contain',
+                    'starts with',
+                    'does not start with',
+                    'ends with',
+                    'does not end with'
+                ])
+            ],
+            'conditions.*.values' => [
+                'required',
+                'array',
+                'min:1',
+                'max:10'
+            ],
+            'conditions.*.values.*' => [
+                'distinct',
+            ],
+            'actions' => [
+                'required',
+                'array',
+                'max:5'
+            ],
+            'actions.*.type' => [
+                'required',
+                Rule::in([
+                    'subject',
+                    'displayFrom',
+                    'encryption',
+                    'banner',
+                    'block',
+                    'webhook'
+                ]),
+            ],
+            'actions.*.value' => [
+                'required',
+                'max:50'
+            ],
+            'operator' => [
+                'required',
+                'in:AND,OR'
+            ]
+        ];
+    }
+}

+ 24 - 0
app/Http/Resources/RuleResource.php

@@ -0,0 +1,24 @@
+<?php
+
+namespace App\Http\Resources;
+
+use Illuminate\Http\Resources\Json\JsonResource;
+
+class RuleResource extends JsonResource
+{
+    public function toArray($request)
+    {
+        return [
+            'id' => $this->id,
+            'user_id' => $this->user_id,
+            'name' => $this->name,
+            'order' => $this->order,
+            'conditions' => $this->conditions,
+            'actions' => $this->actions,
+            'operator' => $this->operator,
+            'active' => $this->active,
+            'created_at' => $this->created_at->toDateTimeString(),
+            'updated_at' => $this->updated_at->toDateTimeString(),
+        ];
+    }
+}

+ 14 - 9
app/Mail/ForwardEmail.php

@@ -8,6 +8,7 @@ use App\Helpers\AlreadyEncryptedSigner;
 use App\Helpers\OpenPGPSigner;
 use App\Helpers\OpenPGPSigner;
 use App\Notifications\GpgKeyExpired;
 use App\Notifications\GpgKeyExpired;
 use App\Recipient;
 use App\Recipient;
+use App\Traits\CheckUserRules;
 use Illuminate\Bus\Queueable;
 use Illuminate\Bus\Queueable;
 use Illuminate\Contracts\Queue\ShouldQueue;
 use Illuminate\Contracts\Queue\ShouldQueue;
 use Illuminate\Mail\Mailable;
 use Illuminate\Mail\Mailable;
@@ -19,8 +20,9 @@ use Swift_SwiftException;
 
 
 class ForwardEmail extends Mailable implements ShouldQueue
 class ForwardEmail extends Mailable implements ShouldQueue
 {
 {
-    use Queueable, SerializesModels;
+    use Queueable, SerializesModels, CheckUserRules;
 
 
+    protected $email;
     protected $user;
     protected $user;
     protected $alias;
     protected $alias;
     protected $sender;
     protected $sender;
@@ -36,6 +38,7 @@ class ForwardEmail extends Mailable implements ShouldQueue
     protected $openpgpsigner;
     protected $openpgpsigner;
     protected $dkimSigner;
     protected $dkimSigner;
     protected $encryptedParts;
     protected $encryptedParts;
+    protected $fromEmail;
 
 
     /**
     /**
      * Create a new message instance.
      * Create a new message instance.
@@ -87,23 +90,23 @@ class ForwardEmail extends Mailable implements ShouldQueue
 
 
         if ($this->alias->isCustomDomain()) {
         if ($this->alias->isCustomDomain()) {
             if ($this->alias->aliasable->isVerifiedForSending()) {
             if ($this->alias->aliasable->isVerifiedForSending()) {
-                $fromEmail = $this->alias->email;
+                $this->fromEmail = $this->alias->email;
                 $returnPath = $this->alias->email;
                 $returnPath = $this->alias->email;
 
 
                 $this->dkimSigner = new Swift_Signers_DKIMSigner(config('anonaddy.dkim_signing_key'), $this->alias->domain, config('anonaddy.dkim_selector'));
                 $this->dkimSigner = new Swift_Signers_DKIMSigner(config('anonaddy.dkim_signing_key'), $this->alias->domain, config('anonaddy.dkim_selector'));
                 $this->dkimSigner->ignoreHeader('List-Unsubscribe');
                 $this->dkimSigner->ignoreHeader('List-Unsubscribe');
                 $this->dkimSigner->ignoreHeader('Return-Path');
                 $this->dkimSigner->ignoreHeader('Return-Path');
             } else {
             } else {
-                $fromEmail = config('mail.from.address');
+                $this->fromEmail = config('mail.from.address');
                 $returnPath = config('anonaddy.return_path');
                 $returnPath = config('anonaddy.return_path');
             }
             }
         } else {
         } else {
-            $fromEmail = $this->alias->email;
+            $this->fromEmail = $this->alias->email;
             $returnPath = 'mailer@'.$this->alias->parentDomain();
             $returnPath = 'mailer@'.$this->alias->parentDomain();
         }
         }
 
 
-        $email =  $this
-            ->from($fromEmail, base64_decode($this->displayFrom)." '".$this->sender."'")
+        $this->email =  $this
+            ->from($this->fromEmail, base64_decode($this->displayFrom)." '".$this->sender."'")
             ->replyTo($replyToEmail)
             ->replyTo($replyToEmail)
             ->subject($this->user->email_subject ?? base64_decode($this->emailSubject))
             ->subject($this->user->email_subject ?? base64_decode($this->emailSubject))
             ->text('emails.forward.text')->with([
             ->text('emails.forward.text')->with([
@@ -141,20 +144,22 @@ class ForwardEmail extends Mailable implements ShouldQueue
             });
             });
 
 
         if ($this->emailHtml) {
         if ($this->emailHtml) {
-            $email->view('emails.forward.html')->with([
+            $this->email->view('emails.forward.html')->with([
                 'html' => base64_decode($this->emailHtml)
                 'html' => base64_decode($this->emailHtml)
             ]);
             ]);
         }
         }
 
 
         foreach ($this->emailAttachments as $attachment) {
         foreach ($this->emailAttachments as $attachment) {
-            $email->attachData(
+            $this->email->attachData(
                 base64_decode($attachment['stream']),
                 base64_decode($attachment['stream']),
                 base64_decode($attachment['file_name']),
                 base64_decode($attachment['file_name']),
                 ['mime' => base64_decode($attachment['mime'])]
                 ['mime' => base64_decode($attachment['mime'])]
             );
             );
         }
         }
 
 
-        return $email;
+        $this->checkRules();
+
+        return $this->email;
     }
     }
 
 
     private function isAlreadyEncrypted()
     private function isAlreadyEncrypted()

+ 55 - 0
app/Rule.php

@@ -0,0 +1,55 @@
+<?php
+
+namespace App;
+
+use App\Traits\HasUuid;
+use Illuminate\Database\Eloquent\Model;
+
+class Rule extends Model
+{
+    use HasUuid;
+
+    public $incrementing = false;
+
+    protected $keyType = 'string';
+
+    protected $fillable = [
+        'name',
+        'conditions',
+        'actions',
+        'operator',
+        'active',
+        'order'
+    ];
+
+    protected $dates = [
+        'created_at',
+        'updated_at'
+    ];
+
+    protected $casts = [
+        'id' => 'string',
+        'user_id' => 'string',
+        'active' => 'boolean',
+        'conditions' => 'array',
+        'actions' => 'array'
+    ];
+
+    /**
+     * Get the user for the rule.
+     */
+    public function user()
+    {
+        return $this->belongsTo(User::class);
+    }
+
+    public function deactivate()
+    {
+        $this->update(['active' => false]);
+    }
+
+    public function activate()
+    {
+        $this->update(['active' => true]);
+    }
+}

+ 53 - 0
app/Rules/ValidRuleId.php

@@ -0,0 +1,53 @@
+<?php
+
+namespace App\Rules;
+
+use Illuminate\Contracts\Validation\Rule;
+
+class ValidRuleId implements Rule
+{
+    protected $user;
+
+    /**
+     * Create a new rule instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        $this->user = user();
+    }
+
+    /**
+     * Determine if the validation rule passes.
+     *
+     * @param  string  $attribute
+     * @param  mixed  $ids
+     * @return bool
+     */
+    public function passes($attribute, $ids)
+    {
+        $validRuleIds = $this->user
+            ->rules()
+            ->pluck('id')
+            ->toArray();
+
+        foreach ($ids as $id) {
+            if (!in_array($id, $validRuleIds)) {
+                return false;
+            }
+        }
+
+        return true;
+    }
+
+    /**
+     * Get the validation error message.
+     *
+     * @return string
+     */
+    public function message()
+    {
+        return 'Invalid Rule ID.';
+    }
+}

+ 133 - 0
app/Traits/CheckUserRules.php

@@ -0,0 +1,133 @@
+<?php
+
+namespace App\Traits;
+
+use Illuminate\Support\Str;
+
+trait CheckUserRules
+{
+    public function checkRules()
+    {
+        $this->user->activeRulesOrdered()->each(function ($rule) {
+            // Check if the conditions of the rule are satisfied
+            if ($this->ruleConditionsSatisfied($rule->conditions, $rule->operator)) {
+                // Apply actions for that rule
+                collect($rule->actions)->each(function ($action) {
+                    $this->applyAction($action);
+                });
+            };
+        });
+    }
+
+    protected function ruleConditionsSatisfied($conditions, $logicalOperator)
+    {
+        $results = collect();
+
+        collect($conditions)->each(function ($condition) use ($results) {
+            $results->push($this->lookupConditionType($condition));
+        });
+
+        $result = $results->unique();
+
+        if ($logicalOperator == 'OR') {
+            return $result->contains(true);
+        }
+
+        // Logical operator is AND so return false if any conditions are not met
+        return ! $result->contains(false);
+    }
+
+    protected function lookupConditionType($condition)
+    {
+        switch ($condition['type']) {
+            case 'sender':
+                return $this->conditionSatisfied($this->sender, $condition);
+                break;
+            case 'subject':
+                return $this->conditionSatisfied($this->subject, $condition);
+                break;
+            case 'alias':
+                return $this->conditionSatisfied($this->alias->email, $condition);
+                break;
+            case 'displayFrom':
+                return $this->conditionSatisfied($this->displayFrom, $condition);
+                break;
+        }
+    }
+
+    protected function conditionSatisfied($variable, $condition)
+    {
+        $values = collect($condition['values']);
+
+        switch ($condition['match']) {
+            case 'is exactly':
+                return $values->contains(function ($value) use ($variable) {
+                    return $variable === $value;
+                });
+                break;
+            case 'is not':
+                return $values->contains(function ($value) use ($variable) {
+                    return $variable !== $value;
+                });
+                break;
+            case 'contains':
+                return $values->contains(function ($value) use ($variable) {
+                    return Str::contains($variable, $value);
+                });
+                break;
+            case 'does not contain':
+                return $values->contains(function ($value) use ($variable) {
+                    return ! Str::contains($variable, $value);
+                });
+                break;
+            case 'starts with':
+                return $values->contains(function ($value) use ($variable) {
+                    return Str::startsWith($variable, $value);
+                });
+                break;
+            case 'does not start with':
+                return $values->contains(function ($value) use ($variable) {
+                    return ! Str::startsWith($variable, $value);
+                });
+                break;
+            case 'ends with':
+                return $values->contains(function ($value) use ($variable) {
+                    return Str::endsWith($variable, $value);
+                });
+                break;
+            case 'does not end with':
+                return $values->contains(function ($value) use ($variable) {
+                    return ! Str::endsWith($variable, $value);
+                });
+                break;
+            // regex preg_match?
+        }
+    }
+
+    protected function applyAction($action)
+    {
+        switch ($action['type']) {
+            case 'subject':
+                $this->email->subject = $action['value'];
+                break;
+            case 'displayFrom':
+                $this->email->from($this->fromEmail, $action['value']);
+                break;
+            case 'encryption':
+                if ($action['value'] == false) {
+                    // detach the openpgpsigner from the email...
+                }
+                break;
+            case 'banner':
+                $this->email->location = $action['value'];
+                break;
+            case 'block':
+                $this->alias->increment('emails_blocked');
+                exit(0);
+                break;
+            case 'webhook':
+                // http payload to url
+                break;
+        }
+    }
+}

+ 24 - 0
app/User.php

@@ -152,6 +152,30 @@ class User extends Authenticatable implements MustVerifyEmail
         return $this->hasMany(Domain::class);
         return $this->hasMany(Domain::class);
     }
     }
 
 
+    /**
+     * Get all of the user's rules.
+     */
+    public function rules()
+    {
+        return $this->hasMany(Rule::class);
+    }
+
+    /**
+     * Get all of the user's active rules.
+     */
+    public function activeRules()
+    {
+        return $this->rules()->where('active', true);
+    }
+
+    /**
+     * Get all of the user's active rules in the correct order.
+     */
+    public function activeRulesOrdered()
+    {
+        return $this->rules()->where('active', true)->orderBy('order');
+    }
+
     /**
     /**
      * Get all of the user's additional usernames.
      * Get all of the user's additional usernames.
      */
      */

File diff suppressed because it is too large
+ 318 - 149
composer.lock


+ 27 - 0
database/factories/RuleFactory.php

@@ -0,0 +1,27 @@
+<?php
+
+/** @var \Illuminate\Database\Eloquent\Factory $factory */
+
+use Faker\Generator as Faker;
+
+$factory->define(App\Rule::class, function (Faker $faker) {
+    return [
+        'name' => $faker->userName,
+        'order' => $faker->randomNumber(1),
+        'conditions' => [
+            [
+                'type' => 'sender',
+                'match' => 'is exactly',
+                'values' => [
+                    'will@anonaddy.com'
+                ]
+            ]
+        ],
+        'actions' => [
+            [
+                'type' => 'subject',
+                'value' => 'New Subject!'
+            ]
+        ]
+    ];
+});

+ 40 - 0
database/migrations/2020_03_05_112308_create_rules_table.php

@@ -0,0 +1,40 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+class CreateRulesTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::create('rules', function (Blueprint $table) {
+            $table->uuid('id');
+            $table->uuid('user_id');
+            $table->string('name');
+            $table->unsignedInteger('order')->default(0);
+            $table->json('conditions');
+            $table->json('actions');
+            $table->string('operator', 3)->default('AND');
+            $table->boolean('active')->default(true);
+            $table->timestamps();
+
+            $table->primary('id');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::dropIfExists('rules');
+    }
+}

+ 17 - 5
package-lock.json

@@ -2674,9 +2674,9 @@
             "dev": true
             "dev": true
         },
         },
         "dayjs": {
         "dayjs": {
-            "version": "1.8.27",
-            "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.8.27.tgz",
-            "integrity": "sha512-Jpa2acjWIeOkg8KURUHICk0EqnEFSSF5eMEscsOgyJ92ZukXwmpmRkPSUka7KHSfbj5eKH30ieosYip+ky9emQ=="
+            "version": "1.8.28",
+            "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.8.28.tgz",
+            "integrity": "sha512-ccnYgKC0/hPSGXxj7Ju6AV/BP4HUkXC2u15mikXT5mX9YorEaoi1bEKOmAqdkJHN4EEkmAf97SpH66Try5Mbeg=="
         },
         },
         "de-indent": {
         "de-indent": {
             "version": "1.0.2",
             "version": "1.0.2",
@@ -8714,6 +8714,11 @@
                 }
                 }
             }
             }
         },
         },
+        "sortablejs": {
+            "version": "1.10.2",
+            "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.10.2.tgz",
+            "integrity": "sha512-YkPGufevysvfwn5rfdlGyrGjt7/CRHwvRPogD/lC+TnvcN29jDpCifKP+rBqf+LRldfXSTh+0CGLcSg0VIxq3A=="
+        },
         "source-list-map": {
         "source-list-map": {
             "version": "2.0.1",
             "version": "2.0.1",
             "resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.1.tgz",
             "resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.1.tgz",
@@ -9982,6 +9987,14 @@
             "resolved": "https://registry.npmjs.org/vue-template-es2015-compiler/-/vue-template-es2015-compiler-1.9.1.tgz",
             "resolved": "https://registry.npmjs.org/vue-template-es2015-compiler/-/vue-template-es2015-compiler-1.9.1.tgz",
             "integrity": "sha512-4gDntzrifFnCEvyoO8PqyJDmguXgVPxKiIxrBKjIowvL9l+N66196+72XVYR8BBf1Uv1Fgt3bGevJ+sEmxfZzw=="
             "integrity": "sha512-4gDntzrifFnCEvyoO8PqyJDmguXgVPxKiIxrBKjIowvL9l+N66196+72XVYR8BBf1Uv1Fgt3bGevJ+sEmxfZzw=="
         },
         },
+        "vuedraggable": {
+            "version": "2.23.2",
+            "resolved": "https://registry.npmjs.org/vuedraggable/-/vuedraggable-2.23.2.tgz",
+            "integrity": "sha512-PgHCjUpxEAEZJq36ys49HfQmXglattf/7ofOzUrW2/rRdG7tu6fK84ir14t1jYv4kdXewTEa2ieKEAhhEMdwkQ==",
+            "requires": {
+                "sortablejs": "^1.10.1"
+            }
+        },
         "watchpack": {
         "watchpack": {
             "version": "1.6.1",
             "version": "1.6.1",
             "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-1.6.1.tgz",
             "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-1.6.1.tgz",
@@ -10462,8 +10475,7 @@
         },
         },
         "yargs-parser": {
         "yargs-parser": {
             "version": "13.1.1",
             "version": "13.1.1",
-            "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-13.1.1.tgz",
-            "integrity": "sha512-oVAVsHz6uFrg3XQheFII8ESO2ssAf9luWuAd6Wexsu4F3OtIW0o8IribPXYrD4WC24LWtPrJlGy87y5udK+dxQ==",
+            "resolved": "",
             "requires": {
             "requires": {
                 "camelcase": "^5.0.0",
                 "camelcase": "^5.0.0",
                 "decamelize": "^1.2.0"
                 "decamelize": "^1.2.0"

+ 3 - 2
package.json

@@ -13,7 +13,7 @@
     "dependencies": {
     "dependencies": {
         "axios": "^0.18.1",
         "axios": "^0.18.1",
         "cross-env": "^5.2.1",
         "cross-env": "^5.2.1",
-        "dayjs": "^1.8.27",
+        "dayjs": "^1.8.28",
         "laravel-mix": "^4.1.4",
         "laravel-mix": "^4.1.4",
         "laravel-mix-purgecss": "^4.2.0",
         "laravel-mix-purgecss": "^4.2.0",
         "lodash": "^4.17.15",
         "lodash": "^4.17.15",
@@ -28,7 +28,8 @@
         "vue-good-table": "^2.19.3",
         "vue-good-table": "^2.19.3",
         "vue-multiselect": "^2.1.6",
         "vue-multiselect": "^2.1.6",
         "vue-notification": "^1.3.20",
         "vue-notification": "^1.3.20",
-        "vue-template-compiler": "^2.6.11"
+        "vue-template-compiler": "^2.6.11",
+        "vuedraggable": "^2.23.2"
     },
     },
     "devDependencies": {
     "devDependencies": {
         "husky": "^2.7.0",
         "husky": "^2.7.0",

+ 1 - 0
resources/js/app.js

@@ -29,6 +29,7 @@ Vue.component('aliases', require('./pages/Aliases.vue').default)
 Vue.component('recipients', require('./pages/Recipients.vue').default)
 Vue.component('recipients', require('./pages/Recipients.vue').default)
 Vue.component('domains', require('./pages/Domains.vue').default)
 Vue.component('domains', require('./pages/Domains.vue').default)
 Vue.component('usernames', require('./pages/Usernames.vue').default)
 Vue.component('usernames', require('./pages/Usernames.vue').default)
+Vue.component('rules', require('./pages/Rules.vue').default)
 
 
 Vue.component(
 Vue.component(
   'passport-personal-access-tokens',
   'passport-personal-access-tokens',

+ 35 - 0
resources/js/components/Icon.vue

@@ -223,6 +223,41 @@
       d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"
       d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"
     ></path>
     ></path>
   </svg>
   </svg>
+
+  <svg
+    v-else-if="name === 'move'"
+    xmlns="http://www.w3.org/2000/svg"
+    viewBox="0 0 24 24"
+    fill="none"
+    stroke="currentColor"
+    stroke-width="2"
+    stroke-linecap="round"
+    stroke-linejoin="round"
+    aria-hidden="true"
+  >
+    <polyline points="5 9 2 12 5 15"></polyline>
+    <polyline points="9 5 12 2 15 5"></polyline>
+    <polyline points="15 19 12 22 9 19"></polyline>
+    <polyline points="19 9 22 12 19 15"></polyline>
+    <line x1="2" y1="12" x2="22" y2="12"></line>
+    <line x1="12" y1="2" x2="12" y2="22"></line>
+  </svg>
+
+  <svg
+    v-else-if="name === 'menu'"
+    xmlns="http://www.w3.org/2000/svg"
+    viewBox="0 0 24 24"
+    fill="none"
+    stroke="currentColor"
+    stroke-width="2"
+    stroke-linecap="round"
+    stroke-linejoin="round"
+    aria-hidden="true"
+  >
+    <line x1="3" y1="12" x2="21" y2="12"></line>
+    <line x1="3" y1="6" x2="21" y2="6"></line>
+    <line x1="3" y1="18" x2="21" y2="18"></line>
+  </svg>
 </template>
 </template>
 
 
 <script>
 <script>

+ 21 - 3
resources/js/components/Modal.vue

@@ -1,6 +1,10 @@
 <template>
 <template>
   <portal to="modals">
   <portal to="modals">
-    <div v-if="showModal" class="fixed inset-0 flex items-center justify-center">
+    <div
+      v-if="showModal"
+      class="fixed inset-0 flex justify-center"
+      :class="overflow ? 'overflow-auto' : 'items-center'"
+    >
       <transition
       <transition
         @before-leave="backdropLeaving = true"
         @before-leave="backdropLeaving = true"
         @after-leave="backdropLeaving = false"
         @after-leave="backdropLeaving = false"
@@ -13,7 +17,11 @@
         appear
         appear
       >
       >
         <div v-if="showBackdrop">
         <div v-if="showBackdrop">
-          <div class="absolute inset-0 bg-black opacity-25" @click="close"></div>
+          <div
+            class="inset-0 bg-black opacity-25"
+            :class="overflow ? 'fixed pointer-events-none' : 'absolute'"
+            @click="close"
+          ></div>
         </div>
         </div>
       </transition>
       </transition>
 
 
@@ -38,7 +46,17 @@
 
 
 <script>
 <script>
 export default {
 export default {
-  props: ['open'],
+  props: {
+    open: {
+      type: Boolean,
+      required: true,
+    },
+    overflow: {
+      type: Boolean,
+      required: false,
+      default: false,
+    },
+  },
   data() {
   data() {
     return {
     return {
       showModal: false,
       showModal: false,

+ 1096 - 0
resources/js/pages/Rules.vue

@@ -0,0 +1,1096 @@
+<template>
+  <div>
+    <div class="mb-6 flex flex-col md:flex-row justify-between md:items-center">
+      <div class="flex items-center">
+        <icon name="move" class="block w-6 h-6 mr-2 text-grey-200 fill-current" />
+        You can drag and drop rules to order them.
+      </div>
+      <button
+        @click="openCreateModal"
+        class="bg-cyan-400 hover:bg-cyan-300 text-cyan-900 font-bold py-3 px-4 rounded focus:outline-none ml-auto"
+      >
+        Add New Rule
+      </button>
+    </div>
+
+    <div v-if="initialRules.length" class="bg-white shadow">
+      <draggable
+        tag="ul"
+        v-model="rows"
+        v-bind="dragOptions"
+        handle=".handle"
+        @change="reorderRules"
+      >
+        <transition-group type="transition" name="flip-list">
+          <li
+            class="relative flex items-center py-3 px-5 border-b border-grey-100"
+            v-for="row in rows"
+            :key="row.name"
+          >
+            <div class="flex items-center w-3/5">
+              <icon
+                name="menu"
+                class="handle block w-6 h-6 text-grey-200 fill-current cursor-pointer"
+              />
+
+              <span class="m-4">{{ row.name }} </span>
+            </div>
+
+            <div class="w-1/5 relative flex">
+              <Toggle
+                v-model="row.active"
+                @on="activateRule(row.id)"
+                @off="deactivateRule(row.id)"
+              />
+            </div>
+
+            <div class="w-1/5 flex justify-end">
+              <icon
+                name="edit"
+                class="block w-6 h-6 mr-3 text-grey-200 fill-current cursor-pointer"
+                @click.native="openEditModal(row)"
+              />
+              <icon
+                name="trash"
+                class="block w-6 h-6 text-grey-200 fill-current cursor-pointer"
+                @click.native="openDeleteModal(row.id)"
+              />
+            </div>
+          </li>
+        </transition-group>
+      </draggable>
+    </div>
+
+    <div v-else class="bg-white rounded shadow overflow-x-auto">
+      <div class="p-8 text-center text-lg text-grey-700">
+        <h1 class="mb-6 text-2xl text-indigo-800 font-semibold">
+          It doesn't look like you have any rules yet!
+        </h1>
+        <div class="mx-auto mb-6 w-24 border-b-2 border-grey-200"></div>
+        <p class="mb-4">
+          Click the button above to create a new rule.
+        </p>
+      </div>
+    </div>
+
+    <Modal :open="createRuleModalOpen" @close="createRuleModalOpen = false" :overflow="true">
+      <div class="max-w-2xl w-full bg-white rounded-lg shadow-2xl p-6 my-12">
+        <h2
+          class="font-semibold text-grey-900 text-2xl leading-tight border-b-2 border-grey-100 pb-4"
+        >
+          Create new rule
+        </h2>
+        <p class="mt-4 text-grey-700">
+          Rules work on all emails, including replies and also send froms. New conditions and
+          actions will be added over time.
+        </p>
+
+        <label for="rule_name" class="block text-grey-700 text-sm my-2">
+          Name:
+        </label>
+        <p v-show="errors.ruleName" class="mb-3 text-red-500 text-sm">
+          {{ errors.ruleName }}
+        </p>
+        <input
+          v-model="createRuleObject.name"
+          id="rule_name"
+          type="text"
+          class="w-full appearance-none bg-grey-100 border border-transparent text-grey-700 focus:outline-none rounded p-2"
+          :class="errors.ruleName ? 'border-red-500' : ''"
+          placeholder="Enter name"
+          autofocus
+        />
+
+        <fieldset class="border border-cyan-400 p-4 my-4 rounded-sm">
+          <legend class="px-2 leading-none text-sm">Conditions</legend>
+
+          <!-- Loop for conditions -->
+          <div v-for="(condition, key) in createRuleObject.conditions" :key="key">
+            <!-- AND/OR operator -->
+            <div v-if="key !== 0" class="flex justify-center my-2">
+              <div class="relative">
+                <select
+                  v-model="createRuleObject.operator"
+                  id="rule_operator"
+                  class="block appearance-none w-full text-grey-700 bg-white p-2 pr-6 rounded shadow focus:shadow-outline"
+                  required
+                >
+                  <option value="AND">AND </option>
+                  <option value="OR">OR </option>
+                </select>
+                <div
+                  class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-gray-700"
+                >
+                  <svg
+                    class="fill-current h-4 w-4"
+                    xmlns="http://www.w3.org/2000/svg"
+                    viewBox="0 0 20 20"
+                  >
+                    <path
+                      d="M9.293 12.95l.707.707L15.657 8l-1.414-1.414L10 10.828 5.757 6.586 4.343 8z"
+                    />
+                  </svg>
+                </div>
+              </div>
+            </div>
+
+            <div class="p-2 w-full bg-grey-100">
+              <div class="flex">
+                <div class="flex items-center">
+                  <span>If</span>
+                  <span class="ml-2">
+                    <div class="relative">
+                      <select
+                        v-model="createRuleObject.conditions[key].type"
+                        id="rule_condition_types"
+                        class="block appearance-none w-32 text-grey-700 bg-white p-2 pr-6 rounded shadow focus:shadow-outline"
+                        required
+                      >
+                        <option
+                          v-for="option in conditionTypeOptions"
+                          :key="option.value"
+                          :value="option.value"
+                          >{{ option.label }}
+                        </option>
+                      </select>
+                      <div
+                        class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-gray-700"
+                      >
+                        <svg
+                          class="fill-current h-4 w-4"
+                          xmlns="http://www.w3.org/2000/svg"
+                          viewBox="0 0 20 20"
+                        >
+                          <path
+                            d="M9.293 12.95l.707.707L15.657 8l-1.414-1.414L10 10.828 5.757 6.586 4.343 8z"
+                          />
+                        </svg>
+                      </div>
+                    </div>
+                  </span>
+
+                  <span
+                    v-if="conditionMatchOptions(createRuleObject, key).length"
+                    class="ml-4 flex"
+                  >
+                    <div class="relative mr-4">
+                      <select
+                        v-model="createRuleObject.conditions[key].match"
+                        id="rule_condition_matches"
+                        class="block appearance-none w-40 text-grey-700 bg-white p-2 pr-6 rounded shadow focus:shadow-outline"
+                        required
+                      >
+                        <option
+                          v-for="option in conditionMatchOptions(createRuleObject, key)"
+                          :key="option"
+                          :value="option"
+                          >{{ option }}
+                        </option>
+                      </select>
+                      <div
+                        class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-gray-700"
+                      >
+                        <svg
+                          class="fill-current h-4 w-4"
+                          xmlns="http://www.w3.org/2000/svg"
+                          viewBox="0 0 20 20"
+                        >
+                          <path
+                            d="M9.293 12.95l.707.707L15.657 8l-1.414-1.414L10 10.828 5.757 6.586 4.343 8z"
+                          />
+                        </svg>
+                      </div>
+                    </div>
+
+                    <div class="flex">
+                      <input
+                        v-model="createRuleObject.conditions[key].currentConditionValue"
+                        @keyup.enter="addValueToCondition(createRuleObject, key)"
+                        type="text"
+                        class="w-full appearance-none bg-white border border-transparent rounded-l text-grey-700 focus:outline-none p-2"
+                        :class="errors.createRuleValues ? 'border-red-500' : ''"
+                        placeholder="Enter value"
+                        autofocus
+                      />
+                      <button class="p-2 bg-grey-200 rounded-r text-grey-600">
+                        <icon
+                          name="check"
+                          class="block w-6 h-6 text-grey-600 fill-current cursor-pointer"
+                          @click.native="addValueToCondition(createRuleObject, key)"
+                        />
+                      </button>
+                    </div>
+                  </span>
+                </div>
+                <div class="flex items-center">
+                  <!-- delete button -->
+                  <icon
+                    v-if="createRuleObject.conditions.length > 1"
+                    name="trash"
+                    class="block ml-4 w-6 h-6 text-grey-200 fill-current cursor-pointer"
+                    @click.native="deleteCondition(createRuleObject, key)"
+                  />
+                </div>
+              </div>
+              <div class="mt-2">
+                <span
+                  v-for="(value, index) in createRuleObject.conditions[key].values"
+                  :key="index"
+                >
+                  <span class="bg-green-200 text-sm font-semibold rounded-sm pl-1">
+                    {{ value }}
+                    <icon
+                      name="close"
+                      class="inline-block w-4 h-4 text-grey-900 fill-current cursor-pointer"
+                      @click.native="createRuleObject.conditions[key].values.splice(index, 1)"
+                    />
+                  </span>
+                  <span
+                    class="mx-1"
+                    v-if="index + 1 !== createRuleObject.conditions[key].values.length"
+                  >
+                    or
+                  </span>
+                </span>
+              </div>
+            </div>
+          </div>
+          <!-- add condition button -->
+          <button
+            @click="addCondition(createRuleObject)"
+            class="mt-4 p-2 text-grey-800 bg-white hover:bg-grey-50 border border-grey-100 rounded focus:outline-none"
+          >
+            Add condition
+          </button>
+
+          <p v-show="errors.ruleConditions" class="mt-2 text-red-500 text-sm">
+            {{ errors.ruleConditions }}
+          </p>
+        </fieldset>
+
+        <fieldset class="border border-cyan-400 p-4 my-4 rounded-sm">
+          <legend class="px-2 leading-none text-sm">Actions</legend>
+
+          <!-- Loop for actions -->
+          <div v-for="(action, key) in createRuleObject.actions" :key="key">
+            <!-- AND/OR operator -->
+            <div v-if="key !== 0" class="flex justify-center my-2">
+              <div class="relative">
+                AND
+              </div>
+            </div>
+
+            <div class="p-2 w-full bg-grey-100">
+              <div class="flex">
+                <div class="flex items-center">
+                  <span>Then</span>
+                  <span class="ml-2">
+                    <div class="relative">
+                      <select
+                        v-model="createRuleObject.actions[key].type"
+                        id="rule_action_types"
+                        class="block appearance-none text-grey-700 bg-white p-2 pr-6 rounded shadow focus:shadow-outline"
+                        required
+                      >
+                        <option
+                          v-for="option in actionTypeOptions"
+                          :key="option.value"
+                          :value="option.value"
+                          >{{ option.label }}
+                        </option>
+                      </select>
+                      <div
+                        class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-gray-700"
+                      >
+                        <svg
+                          class="fill-current h-4 w-4"
+                          xmlns="http://www.w3.org/2000/svg"
+                          viewBox="0 0 20 20"
+                        >
+                          <path
+                            d="M9.293 12.95l.707.707L15.657 8l-1.414-1.414L10 10.828 5.757 6.586 4.343 8z"
+                          />
+                        </svg>
+                      </div>
+                    </div>
+                  </span>
+
+                  <span v-if="createRuleObject.actions[key].type === 'subject'" class="ml-4 flex">
+                    <div class="flex">
+                      <input
+                        v-model="createRuleObject.actions[key].value"
+                        type="text"
+                        class="w-full appearance-none bg-white border border-transparent rounded text-grey-700 focus:outline-none p-2"
+                        :class="errors.createRuleActionValue ? 'border-red-500' : ''"
+                        placeholder="Enter value"
+                        autofocus
+                      />
+                    </div>
+                  </span>
+                </div>
+                <div class="flex items-center">
+                  <!-- delete button -->
+                  <icon
+                    v-if="createRuleObject.actions.length > 1"
+                    name="trash"
+                    class="block ml-4 w-6 h-6 text-grey-200 fill-current cursor-pointer"
+                    @click.native="deleteAction(createRuleObject, key)"
+                  />
+                </div>
+              </div>
+            </div>
+          </div>
+          <!-- add action button -->
+          <button
+            @click="addAction(createRuleObject)"
+            class="mt-4 p-2 text-grey-800 bg-white hover:bg-grey-50 border border-grey-100 rounded focus:outline-none"
+          >
+            Add action
+          </button>
+
+          <p v-show="errors.ruleActions" class="mt-2 text-red-500 text-sm">
+            {{ errors.ruleActions }}
+          </p>
+        </fieldset>
+
+        <div class="mt-6">
+          <button
+            @click="createNewRule"
+            class="bg-cyan-400 hover:bg-cyan-300 text-cyan-900 font-bold py-3 px-4 rounded focus:outline-none"
+            :class="createRuleLoading ? 'cursor-not-allowed' : ''"
+            :disabled="createRuleLoading"
+          >
+            Create Rule
+            <loader v-if="createRuleLoading" />
+          </button>
+          <button
+            @click="createRuleModalOpen = false"
+            class="ml-4 px-4 py-3 text-grey-800 font-semibold bg-white hover:bg-grey-50 border border-grey-100 rounded focus:outline-none"
+          >
+            Cancel
+          </button>
+        </div>
+      </div>
+    </Modal>
+
+    <Modal :open="editRuleModalOpen" @close="closeEditModal" :overflow="true">
+      <div class="max-w-2xl w-full bg-white rounded-lg shadow-2xl p-6 my-12">
+        <h2
+          class="font-semibold text-grey-900 text-2xl leading-tight border-b-2 border-grey-100 pb-4"
+        >
+          Edit rule
+        </h2>
+        <p class="mt-4 text-grey-700">
+          Rules work on all emails, including replies and also send froms. New conditions and
+          actions will be added over time.
+        </p>
+
+        <label for="edit_rule_name" class="block text-grey-700 text-sm my-2">
+          Name:
+        </label>
+        <p v-show="errors.ruleName" class="mb-3 text-red-500 text-sm">
+          {{ errors.ruleName }}
+        </p>
+        <input
+          v-model="editRuleObject.name"
+          id="edit_rule_name"
+          type="text"
+          class="w-full appearance-none bg-grey-100 border border-transparent text-grey-700 focus:outline-none rounded p-2"
+          :class="errors.ruleName ? 'border-red-500' : ''"
+          placeholder="Enter name"
+          autofocus
+        />
+
+        <fieldset class="border border-cyan-400 p-4 my-4 rounded-sm">
+          <legend class="px-2 leading-none text-sm">Conditions</legend>
+
+          <!-- Loop for conditions -->
+          <div v-for="(condition, key) in editRuleObject.conditions" :key="key">
+            <!-- AND/OR operator -->
+            <div v-if="key !== 0" class="flex justify-center my-2">
+              <div class="relative">
+                <select
+                  v-model="editRuleObject.operator"
+                  id="edit_rule_operator"
+                  class="block appearance-none w-full text-grey-700 bg-white p-2 pr-6 rounded shadow focus:shadow-outline"
+                  required
+                >
+                  <option value="AND">AND </option>
+                  <option value="OR">OR </option>
+                </select>
+                <div
+                  class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-gray-700"
+                >
+                  <svg
+                    class="fill-current h-4 w-4"
+                    xmlns="http://www.w3.org/2000/svg"
+                    viewBox="0 0 20 20"
+                  >
+                    <path
+                      d="M9.293 12.95l.707.707L15.657 8l-1.414-1.414L10 10.828 5.757 6.586 4.343 8z"
+                    />
+                  </svg>
+                </div>
+              </div>
+            </div>
+
+            <div class="p-2 w-full bg-grey-100">
+              <div class="flex">
+                <div class="flex items-center">
+                  <span>If</span>
+                  <span class="ml-2">
+                    <div class="relative">
+                      <select
+                        v-model="editRuleObject.conditions[key].type"
+                        id="edit_rule_condition_types"
+                        class="block appearance-none w-32 text-grey-700 bg-white p-2 pr-6 rounded shadow focus:shadow-outline"
+                        required
+                      >
+                        <option
+                          v-for="option in conditionTypeOptions"
+                          :key="option.value"
+                          :value="option.value"
+                          >{{ option.label }}
+                        </option>
+                      </select>
+                      <div
+                        class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-gray-700"
+                      >
+                        <svg
+                          class="fill-current h-4 w-4"
+                          xmlns="http://www.w3.org/2000/svg"
+                          viewBox="0 0 20 20"
+                        >
+                          <path
+                            d="M9.293 12.95l.707.707L15.657 8l-1.414-1.414L10 10.828 5.757 6.586 4.343 8z"
+                          />
+                        </svg>
+                      </div>
+                    </div>
+                  </span>
+
+                  <span v-if="conditionMatchOptions(editRuleObject, key).length" class="ml-4 flex">
+                    <div class="relative mr-4">
+                      <select
+                        v-model="editRuleObject.conditions[key].match"
+                        id="edit_rule_condition_matches"
+                        class="block appearance-none w-40 text-grey-700 bg-white p-2 pr-6 rounded shadow focus:shadow-outline"
+                        required
+                      >
+                        <option
+                          v-for="option in conditionMatchOptions(editRuleObject, key)"
+                          :key="option"
+                          :value="option"
+                          >{{ option }}
+                        </option>
+                      </select>
+                      <div
+                        class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-gray-700"
+                      >
+                        <svg
+                          class="fill-current h-4 w-4"
+                          xmlns="http://www.w3.org/2000/svg"
+                          viewBox="0 0 20 20"
+                        >
+                          <path
+                            d="M9.293 12.95l.707.707L15.657 8l-1.414-1.414L10 10.828 5.757 6.586 4.343 8z"
+                          />
+                        </svg>
+                      </div>
+                    </div>
+
+                    <div class="flex">
+                      <input
+                        v-model="editRuleObject.conditions[key].currentConditionValue"
+                        @keyup.enter="addValueToCondition(editRuleObect, key)"
+                        type="text"
+                        class="w-full appearance-none bg-white border border-transparent rounded-l text-grey-700 focus:outline-none p-2"
+                        :class="errors.ruleConditions ? 'border-red-500' : ''"
+                        placeholder="Enter value"
+                        autofocus
+                      />
+                      <button class="p-2 bg-grey-200 rounded-r text-grey-600">
+                        <icon
+                          name="check"
+                          class="block w-6 h-6 text-grey-600 fill-current cursor-pointer"
+                          @click.native="addValueToCondition(editRuleObject, key)"
+                        />
+                      </button>
+                    </div>
+                  </span>
+                </div>
+                <div class="flex items-center">
+                  <!-- delete button -->
+                  <icon
+                    v-if="editRuleObject.conditions.length > 1"
+                    name="trash"
+                    class="block ml-4 w-6 h-6 text-grey-200 fill-current cursor-pointer"
+                    @click.native="deleteCondition(editRuleObject, key)"
+                  />
+                </div>
+              </div>
+              <div class="mt-2">
+                <span v-for="(value, index) in editRuleObject.conditions[key].values" :key="index">
+                  <span class="bg-green-200 text-sm font-semibold rounded-sm pl-1">
+                    {{ value }}
+                    <icon
+                      name="close"
+                      class="inline-block w-4 h-4 text-grey-900 fill-current cursor-pointer"
+                      @click.native="editRuleObject.conditions[key].values.splice(index, 1)"
+                    />
+                  </span>
+                  <span
+                    class="mx-1"
+                    v-if="index + 1 !== editRuleObject.conditions[key].values.length"
+                  >
+                    or
+                  </span>
+                </span>
+              </div>
+            </div>
+          </div>
+          <!-- add condition button -->
+          <button
+            @click="addCondition(editRuleObject)"
+            class="mt-4 p-2 text-grey-800 bg-white hover:bg-grey-50 border border-grey-100 rounded focus:outline-none"
+          >
+            Add condition
+          </button>
+
+          <p v-show="errors.ruleConditions" class="mt-2 text-red-500 text-sm">
+            {{ errors.ruleConditions }}
+          </p>
+        </fieldset>
+
+        <fieldset class="border border-cyan-400 p-4 my-4 rounded-sm">
+          <legend class="px-2 leading-none text-sm">Actions</legend>
+
+          <!-- Loop for actions -->
+          <div v-for="(action, key) in editRuleObject.actions" :key="key">
+            <!-- AND/OR operator -->
+            <div v-if="key !== 0" class="flex justify-center my-2">
+              <div class="relative">
+                AND
+              </div>
+            </div>
+
+            <div class="p-2 w-full bg-grey-100">
+              <div class="flex">
+                <div class="flex items-center">
+                  <span>Then</span>
+                  <span class="ml-2">
+                    <div class="relative">
+                      <select
+                        v-model="editRuleObject.actions[key].type"
+                        id="rule_action_types"
+                        class="block appearance-none text-grey-700 bg-white p-2 pr-6 rounded shadow focus:shadow-outline"
+                        required
+                      >
+                        <option
+                          v-for="option in actionTypeOptions"
+                          :key="option.value"
+                          :value="option.value"
+                          >{{ option.label }}
+                        </option>
+                      </select>
+                      <div
+                        class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-gray-700"
+                      >
+                        <svg
+                          class="fill-current h-4 w-4"
+                          xmlns="http://www.w3.org/2000/svg"
+                          viewBox="0 0 20 20"
+                        >
+                          <path
+                            d="M9.293 12.95l.707.707L15.657 8l-1.414-1.414L10 10.828 5.757 6.586 4.343 8z"
+                          />
+                        </svg>
+                      </div>
+                    </div>
+                  </span>
+
+                  <span v-if="editRuleObject.actions[key].type === 'subject'" class="ml-4 flex">
+                    <div class="flex">
+                      <input
+                        v-model="editRuleObject.actions[key].value"
+                        type="text"
+                        class="w-full appearance-none bg-white border border-transparent rounded text-grey-700 focus:outline-none p-2"
+                        :class="errors.ruleActions ? 'border-red-500' : ''"
+                        placeholder="Enter value"
+                        autofocus
+                      />
+                    </div>
+                  </span>
+                </div>
+                <div class="flex items-center">
+                  <!-- delete button -->
+                  <icon
+                    v-if="editRuleObject.actions.length > 1"
+                    name="trash"
+                    class="block ml-4 w-6 h-6 text-grey-200 fill-current cursor-pointer"
+                    @click.native="deleteAction(editRuleObject, key)"
+                  />
+                </div>
+              </div>
+            </div>
+          </div>
+          <!-- add action button -->
+          <button
+            @click="addAction(editRuleObject)"
+            class="mt-4 p-2 text-grey-800 bg-white hover:bg-grey-50 border border-grey-100 rounded focus:outline-none"
+          >
+            Add action
+          </button>
+
+          <p v-show="errors.ruleActions" class="mt-2 text-red-500 text-sm">
+            {{ errors.ruleActions }}
+          </p>
+        </fieldset>
+
+        <div class="mt-6">
+          <button
+            @click="editRule"
+            class="bg-cyan-400 hover:bg-cyan-300 text-cyan-900 font-bold py-3 px-4 rounded focus:outline-none"
+            :class="editRuleLoading ? 'cursor-not-allowed' : ''"
+            :disabled="editRuleLoading"
+          >
+            Edit Rule
+            <loader v-if="editRuleLoading" />
+          </button>
+          <button
+            @click="closeEditModal"
+            class="ml-4 px-4 py-3 text-grey-800 font-semibold bg-white hover:bg-grey-50 border border-grey-100 rounded focus:outline-none"
+          >
+            Cancel
+          </button>
+        </div>
+      </div>
+    </Modal>
+
+    <Modal :open="deleteRuleModalOpen" @close="closeDeleteModal">
+      <div class="max-w-lg w-full bg-white rounded-lg shadow-2xl px-6 py-6">
+        <h2
+          class="font-semibold text-grey-900 text-2xl leading-tight border-b-2 border-grey-100 pb-4"
+        >
+          Delete rule
+        </h2>
+        <p class="mt-4 text-grey-700">
+          Are you sure you want to delete this rule?
+        </p>
+        <div class="mt-6">
+          <button
+            type="button"
+            @click="deleteRule(ruleIdToDelete)"
+            class="px-4 py-3 text-white font-semibold bg-red-500 hover:bg-red-600 border border-transparent rounded focus:outline-none"
+            :class="deleteRuleLoading ? 'cursor-not-allowed' : ''"
+            :disabled="deleteRuleLoading"
+          >
+            Delete rule
+            <loader v-if="deleteRuleLoading" />
+          </button>
+          <button
+            @click="closeDeleteModal"
+            class="ml-4 px-4 py-3 text-grey-800 font-semibold bg-white hover:bg-grey-50 border border-grey-100 rounded focus:outline-none"
+          >
+            Cancel
+          </button>
+        </div>
+      </div>
+    </Modal>
+  </div>
+</template>
+
+<script>
+import Modal from './../components/Modal.vue'
+import Toggle from './../components/Toggle.vue'
+import tippy from 'tippy.js'
+import draggable from 'vuedraggable'
+
+export default {
+  props: {
+    initialRules: {
+      type: Array,
+      required: true,
+    },
+  },
+  components: {
+    Modal,
+    Toggle,
+    draggable,
+  },
+  mounted() {
+    this.addTooltips()
+  },
+  data() {
+    return {
+      editRuleObject: {},
+      ruleIdToDelete: '',
+      deleteRuleLoading: false,
+      deleteRuleModalOpen: false,
+      createRuleModalOpen: false,
+      editRuleModalOpen: false,
+      createRuleLoading: false,
+      editRuleLoading: false,
+      createRuleObject: {
+        name: '',
+        conditions: [
+          {
+            type: 'select',
+            match: 'contains',
+            values: [],
+          },
+        ],
+        actions: [
+          {
+            type: 'select',
+            value: '',
+          },
+        ],
+        operator: 'AND',
+      },
+      rows: this.initialRules,
+      conditionTypeOptions: [
+        {
+          value: 'select',
+          label: 'Select',
+        },
+        {
+          value: 'sender',
+          label: 'the sender',
+        },
+        {
+          value: 'subject',
+          label: 'the subject',
+        },
+        {
+          value: 'alias',
+          label: 'the alias',
+        },
+      ],
+      actionTypeOptions: [
+        {
+          value: 'select',
+          label: 'Select',
+        },
+        {
+          value: 'subject',
+          label: 'replace the subject with',
+        },
+      ],
+      errors: {},
+    }
+  },
+  watch: {
+    editRuleObject: _.debounce(function() {
+      this.addTooltips()
+    }, 50),
+  },
+  computed: {
+    activeRules() {
+      return _.filter(this.rows, rule => rule.active)
+    },
+    dragOptions() {
+      return {
+        animation: 0,
+        group: 'description',
+        disabled: false,
+        ghostClass: 'ghost',
+      }
+    },
+    rowsIds() {
+      return _.map(this.rows, row => row.id)
+    },
+  },
+  methods: {
+    addTooltips() {
+      tippy('.tooltip', {
+        arrow: true,
+        arrowType: 'round',
+      })
+    },
+    debounceToolips: _.debounce(function() {
+      this.addTooltips()
+    }, 50),
+    openCreateModal() {
+      this.errors = {}
+      this.createRuleModalOpen = true
+    },
+    openDeleteModal(id) {
+      this.deleteRuleModalOpen = true
+      this.ruleIdToDelete = id
+    },
+    closeDeleteModal() {
+      this.deleteRuleModalOpen = false
+      this.ruleIdToDelete = ''
+    },
+    openEditModal(rule) {
+      this.errors = {}
+      this.editRuleModalOpen = true
+      this.editRuleObject = _.cloneDeep(rule)
+    },
+    closeEditModal() {
+      this.editRuleModalOpen = false
+      this.editRuleObject = {}
+    },
+    deleteRule(id) {
+      this.deleteRuleLoading = true
+
+      axios
+        .delete(`/api/v1/rules/${id}`)
+        .then(response => {
+          this.rows = _.reject(this.rows, rule => rule.id === id)
+          this.deleteRuleModalOpen = false
+          this.deleteRuleLoading = false
+        })
+        .catch(error => {
+          this.error()
+          this.deleteRuleModalOpen = false
+          this.deleteRuleLoading = false
+        })
+    },
+    createNewRule() {
+      this.errors = {}
+
+      if (!this.createRuleObject.name.length) {
+        return (this.errors.ruleName = 'Please enter a rule name')
+      }
+
+      if (this.createRuleObject.name.length > 50) {
+        return (this.errors.ruleName = 'Rule name cannot exceed 50 characters')
+      }
+
+      if (!this.createRuleObject.conditions[0].values.length) {
+        return (this.errors.ruleConditions = 'You must add some values for the condition')
+      }
+
+      if (!this.createRuleObject.actions[0].value) {
+        return (this.errors.ruleActions = 'You must add a value for the action')
+      }
+
+      this.createRuleLoading = true
+
+      axios
+        .post(
+          '/api/v1/rules',
+          JSON.stringify({
+            name: this.createRuleObject.name,
+            conditions: this.createRuleObject.conditions,
+            actions: this.createRuleObject.actions,
+            operator: this.createRuleObject.operator,
+          }),
+          {
+            headers: { 'Content-Type': 'application/json' },
+          }
+        )
+        .then(({ data }) => {
+          this.createRuleLoading = false
+          this.resetCreateRuleObject()
+          this.rows.push(data.data)
+          this.createRuleModalOpen = false
+          this.success('New rule created successfully')
+        })
+        .catch(error => {
+          this.createRuleLoading = false
+          this.error()
+        })
+    },
+    editRule() {
+      this.errors = {}
+
+      if (!this.editRuleObject.name.length) {
+        return (this.errors.ruleName = 'Please enter a rule name')
+      }
+
+      if (this.editRuleObject.name.length > 50) {
+        return (this.errors.ruleName = 'Rule name cannot exceed 50 characters')
+      }
+
+      if (!this.editRuleObject.conditions[0].values.length) {
+        return (this.errors.ruleConditions = 'You must add some values for the condition')
+      }
+
+      if (!this.editRuleObject.actions[0].value) {
+        return (this.errors.ruleActions = 'You must add a value for the action')
+      }
+
+      this.editRuleLoading = true
+
+      axios
+        .patch(
+          `/api/v1/rules/${this.editRuleObject.id}`,
+          JSON.stringify({
+            name: this.editRuleObject.name,
+            conditions: this.editRuleObject.conditions,
+            actions: this.editRuleObject.actions,
+            operator: this.editRuleObject.operator,
+          }),
+          {
+            headers: { 'Content-Type': 'application/json' },
+          }
+        )
+        .then(response => {
+          let rule = _.find(this.rows, ['id', this.editRuleObject.id])
+
+          this.editRuleLoading = false
+          rule.name = this.editRuleObject.name
+          rule.conditions = this.editRuleObject.conditions
+          rule.actions = this.editRuleObject.actions
+          rule.operator = this.editRuleObject.operator
+          this.editRuleObject = {}
+          this.editRuleModalOpen = false
+          this.success('Rule successfully updated')
+        })
+        .catch(error => {
+          this.editRuleLoading = false
+          this.editRuleObject = {}
+          this.error()
+        })
+    },
+    activateRule(id) {
+      axios
+        .post(
+          `/api/v1/active-rules`,
+          JSON.stringify({
+            id: id,
+          }),
+          {
+            headers: { 'Content-Type': 'application/json' },
+          }
+        )
+        .then(response => {
+          //
+        })
+        .catch(error => {
+          if (error.response !== undefined) {
+            this.error(error.response.data)
+          } else {
+            this.error()
+          }
+        })
+    },
+    deactivateRule(id) {
+      axios
+        .delete(`/api/v1/active-rules/${id}`)
+        .then(response => {
+          //
+        })
+        .catch(error => {
+          if (error.response !== undefined) {
+            this.error(error.response.data)
+          } else {
+            this.error()
+          }
+        })
+    },
+    reorderRules() {
+      axios
+        .post(
+          `/api/v1/reorder-rules`,
+          JSON.stringify({
+            ids: this.rowsIds,
+          }),
+          {
+            headers: { 'Content-Type': 'application/json' },
+          }
+        )
+        .then(response => {
+          this.success('Rule order successfully updated')
+        })
+        .catch(error => {
+          if (error.response !== undefined) {
+            this.error(error.response.data)
+          } else {
+            this.error()
+          }
+        })
+    },
+    conditionMatchOptions(object, key) {
+      if (_.includes(['sender', 'subject', 'alias'], object.conditions[key].type)) {
+        return [
+          'contains',
+          'does not contain',
+          'is exactly',
+          'is not',
+          'starts with',
+          'does not start with',
+          'ends with',
+          'does not end with',
+        ]
+      }
+
+      return []
+    },
+    addCondition(object) {
+      object.conditions.push({
+        type: 'select',
+        match: 'contains',
+        values: [],
+      })
+    },
+    deleteCondition(object, key) {
+      object.conditions.splice(key, 1)
+    },
+    addValueToCondition(object, key) {
+      if (object.conditions[key].currentConditionValue) {
+        object.conditions[key].values.push(object.conditions[key].currentConditionValue)
+      }
+
+      // Reset current conditon value input
+      object.conditions[key].currentConditionValue = ''
+    },
+    addAction(object) {
+      object.actions.push({
+        type: 'select',
+        value: '',
+      })
+    },
+    deleteAction(object, key) {
+      object.actions.splice(key, 1)
+    },
+    resetCreateRuleObject() {
+      this.createRuleObject = {
+        name: '',
+        conditions: [
+          {
+            type: 'select',
+            match: 'contains',
+            values: [],
+          },
+        ],
+        actions: [
+          {
+            type: 'select',
+            value: '',
+          },
+        ],
+        operator: 'AND',
+      }
+    },
+    success(text = '') {
+      this.$notify({
+        title: 'Success',
+        text: text,
+        type: 'success',
+      })
+    },
+    error(text = 'An error has occurred, please try again later') {
+      this.$notify({
+        title: 'Error',
+        text: text,
+        type: 'error',
+      })
+    },
+  },
+}
+</script>
+
+<style>
+.flip-list-move {
+  transition: transform 0.5s;
+}
+
+.ghost {
+  opacity: 0.5;
+  background: #c8ebfb;
+}
+</style>

+ 3 - 0
resources/views/nav/nav.blade.php

@@ -26,6 +26,9 @@
                     <a href="{{ route('usernames.index') }}" class="block mt-4 md:inline-block md:mt-0 hover:text-white mr-4 {{ Route::currentRouteNamed('usernames.index') ? 'text-white' : 'text-indigo-100' }}">
                     <a href="{{ route('usernames.index') }}" class="block mt-4 md:inline-block md:mt-0 hover:text-white mr-4 {{ Route::currentRouteNamed('usernames.index') ? 'text-white' : 'text-indigo-100' }}">
                         Usernames
                         Usernames
                     </a>
                     </a>
+                    <a href="{{ route('rules.index') }}" class="block mt-4 md:inline-block md:mt-0 hover:text-white mr-4 {{ Route::currentRouteNamed('rules.index') ? 'text-white' : 'text-indigo-100' }}">
+                        Rules
+                    </a>
 
 
                     <a href="{{ route('settings.show') }}" class="block md:hidden mt-4 hover:text-white mr-4 {{ Route::currentRouteNamed('settings.show') ? 'text-white' : 'text-indigo-100' }}">
                     <a href="{{ route('settings.show') }}" class="block md:hidden mt-4 hover:text-white mr-4 {{ Route::currentRouteNamed('settings.show') ? 'text-white' : 'text-indigo-100' }}">
                         Settings
                         Settings

+ 9 - 0
resources/views/rules/index.blade.php

@@ -0,0 +1,9 @@
+@extends('layouts.app')
+
+@section('content')
+    <div class="container py-8">
+        @include('shared.status')
+
+        <rules :initial-rules="{{ json_encode($rules) }}" />
+    </div>
+@endsection

+ 10 - 0
routes/api.php

@@ -57,5 +57,15 @@ Route::group([
     Route::post('/active-usernames', 'Api\ActiveAdditionalUsernameController@store');
     Route::post('/active-usernames', 'Api\ActiveAdditionalUsernameController@store');
     Route::delete('/active-usernames/{id}', 'Api\ActiveAdditionalUsernameController@destroy');
     Route::delete('/active-usernames/{id}', 'Api\ActiveAdditionalUsernameController@destroy');
 
 
+    Route::get('/rules', 'Api\RuleController@index');
+    Route::get('/rules/{id}', 'Api\RuleController@show');
+    Route::post('/rules', 'Api\RuleController@store');
+    Route::patch('/rules/{id}', 'Api\RuleController@update');
+    Route::delete('/rules/{id}', 'Api\RuleController@destroy');
+    Route::post('/reorder-rules', 'Api\ReorderRuleController@store');
+
+    Route::post('/active-rules', 'Api\ActiveRuleController@store');
+    Route::delete('/active-rules/{id}', 'Api\ActiveRuleController@destroy');
+
     Route::get('/domain-options', 'Api\DomainOptionController@index');
     Route::get('/domain-options', 'Api\DomainOptionController@index');
 });
 });

+ 2 - 0
routes/web.php

@@ -30,6 +30,8 @@ Route::middleware(['auth', 'verified', '2fa'])->group(function () {
     Route::get('/usernames', 'ShowAdditionalUsernameController@index')->name('usernames.index');
     Route::get('/usernames', 'ShowAdditionalUsernameController@index')->name('usernames.index');
 
 
     Route::get('/deactivate/{alias}', 'DeactivateAliasController@deactivate')->name('deactivate');
     Route::get('/deactivate/{alias}', 'DeactivateAliasController@deactivate')->name('deactivate');
+
+    Route::get('/rules', 'ShowRuleController@index')->name('rules.index');
 });
 });
 
 
 
 

+ 427 - 0
tests/Feature/Api/RulesTest.php

@@ -0,0 +1,427 @@
+<?php
+
+namespace Tests\Feature\Api;
+
+use App\Alias;
+use App\EmailData;
+use App\Mail\ForwardEmail;
+use App\Rule;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Str;
+use PhpMimeMailParser\Parser;
+use Tests\TestCase;
+
+class RulesTest extends TestCase
+{
+    use RefreshDatabase;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+        parent::setUpPassport();
+
+        $this->user->update(['username' => 'johndoe']);
+        $this->user->recipients()->save($this->user->defaultRecipient);
+    }
+
+    /** @test */
+    public function user_can_get_all_rules()
+    {
+        // Arrange
+        factory(Rule::class, 3)->create([
+            'user_id' => $this->user->id
+        ]);
+
+        // At
+        $response = $this->get('/api/v1/rules');
+
+        // Assert
+        $response->assertSuccessful();
+        $this->assertCount(3, $response->json()['data']);
+    }
+
+    /** @test */
+    public function user_can_get_individual_rule()
+    {
+        // Arrange
+        $rule = factory(Rule::class)->create([
+            'user_id' => $this->user->id
+        ]);
+
+        // Act
+        $response = $this->get('/api/v1/rules/'.$rule->id);
+
+        // Assert
+        $response->assertSuccessful();
+        $this->assertCount(1, $response->json());
+        $this->assertEquals($rule->name, $response->json()['data']['name']);
+    }
+
+    /** @test */
+    public function user_can_create_new_rule()
+    {
+        $response = $this->json('POST', '/api/v1/rules', [
+            'name' => 'test rule',
+            'conditions' => [
+                [
+                    'type' => 'sender',
+                    'match' => 'is exactly',
+                    'values' => [
+                        'Test Email'
+                    ]
+                ],
+                [
+                    'type' => 'sender',
+                    'match' => 'starts with',
+                    'values' => [
+                        'will'
+                    ]
+                ],
+                [
+                    'type' => 'alias',
+                    'match' => 'is exactly',
+                    'values' => [
+                        'ebay@johndoe.anonaddy.com'
+                    ]
+                ]
+            ],
+            'actions' => [
+                [
+                    'type' => 'subject',
+                    'value' => 'New Subject!'
+                ],
+            ],
+            'operator' => 'AND'
+        ]);
+
+        $response->assertStatus(201);
+        $this->assertEquals('test rule', $response->getData()->data->name);
+    }
+
+    /** @test */
+    public function user_cannot_create_invalid_rule()
+    {
+        $response = $this->json('POST', '/api/v1/rules', [
+            'name' => 'invalid rule',
+            'conditions' => [
+                [
+                    'type' => 'invalid',
+                    'match' => 'is exactly',
+                    'values' => [
+                        'Test Email'
+                    ]
+                ]
+            ],
+            'actions' => [
+                [
+                    'type' => 'subject',
+                    'value' => 'New Subject!'
+                ],
+            ],
+            'operator' => 'AND'
+        ]);
+
+        $response->assertStatus(422);
+    }
+
+    /** @test */
+    public function user_can_update_rule()
+    {
+        $rule = factory(Rule::class)->create([
+            'user_id' => $this->user->id,
+            'operator' => 'AND'
+        ]);
+
+        $response = $this->json('PATCH', '/api/v1/rules/'.$rule->id, [
+            'name' => 'new name',
+            'conditions' => [
+                [
+                    'type' => 'subject',
+                    'match' => 'is exactly',
+                    'values' => [
+                        'Test Email'
+                    ]
+                ]
+            ],
+            'actions' => [
+                [
+                    'type' => 'subject',
+                    'value' => 'New Subject!'
+                ],
+            ],
+            'operator' => 'OR'
+        ]);
+
+        $response->assertStatus(200);
+        $this->assertEquals('new name', $response->getData()->data->name);
+        $this->assertEquals('OR', $response->getData()->data->operator);
+    }
+
+    /** @test */
+    public function user_can_delete_rule()
+    {
+        $rule = factory(Rule::class)->create([
+            'user_id' => $this->user->id
+        ]);
+
+        $response = $this->json('DELETE', '/api/v1/rules/'.$rule->id);
+
+        $response->assertStatus(204);
+        $this->assertEmpty($this->user->rules);
+    }
+
+    /** @test */
+    public function user_can_activate_rule()
+    {
+        $rule = factory(Rule::class)->create([
+            'user_id' => $this->user->id,
+            'active' => false
+        ]);
+
+        $response = $this->json('POST', '/api/v1/active-rules/', [
+            'id' => $rule->id
+        ]);
+
+        $response->assertStatus(200);
+        $this->assertEquals(true, $response->getData()->data->active);
+    }
+
+    /** @test */
+    public function user_can_deactivate_rule()
+    {
+        $rule = factory(Rule::class)->create([
+            'user_id' => $this->user->id,
+            'active' => true
+        ]);
+
+        $response = $this->json('DELETE', '/api/v1/active-rules/'.$rule->id);
+
+        $response->assertStatus(204);
+        $this->assertFalse($this->user->rules[0]->active);
+    }
+
+    /** @test */
+    public function it_can_apply_user_rules()
+    {
+        factory(Rule::class)->create([
+            'user_id' => $this->user->id,
+            'conditions' => [
+                [
+                    'type' => 'subject',
+                    'match' => 'is exactly',
+                    'values' => [
+                        'Test Email'
+                    ]
+                ],
+                [
+                    'type' => 'sender',
+                    'match' => 'starts with',
+                    'values' => [
+                        'will'
+                    ]
+                ],
+                [
+                    'type' => 'alias',
+                    'match' => 'is exactly',
+                    'values' => [
+                        'ebay@johndoe.anonaddy.com'
+                    ]
+                ]
+            ],
+            'actions' => [
+                [
+                    'type' => 'subject',
+                    'value' => 'New Subject!'
+                ],
+            ],
+            'operator' => 'AND',
+        ]);
+
+        $alias = factory(Alias::class)->create([
+            'user_id' => $this->user->id,
+            'email' => 'ebay@johndoe.'.config('anonaddy.domain'),
+            'local_part' => 'ebay',
+            'domain' => 'johndoe.'.config('anonaddy.domain'),
+        ]);
+
+        $parser = $this->getParser(base_path('tests/emails/email.eml'));
+
+        $emailData = new EmailData($parser);
+
+        $job = new ForwardEmail($alias, $emailData, $this->user->defaultRecipient);
+
+        $email = $job->build();
+
+        $this->assertEquals('New Subject!', $email->subject);
+    }
+
+    /** @test */
+    public function it_can_apply_user_rules_in_correct_order()
+    {
+        factory(Rule::class)->create([
+            'user_id' => $this->user->id,
+            'conditions' => [
+                [
+                    'type' => 'alias',
+                    'match' => 'is not',
+                    'values' => [
+                        'woot@johndoe.anonaddy.com'
+                    ]
+                ]
+            ],
+            'actions' => [
+                [
+                    'type' => 'subject',
+                    'value' => 'Applied after'
+                ],
+            ],
+            'operator' => 'AND',
+            'order' => 1
+        ]);
+
+        factory(Rule::class)->create([
+            'user_id' => $this->user->id,
+            'conditions' => [
+                [
+                    'type' => 'subject',
+                    'match' => 'is',
+                    'values' => [
+                        'Test Email'
+                    ]
+                ],
+                [
+                    'type' => 'sender',
+                    'match' => 'ends with',
+                    'values' => [
+                        'anonaddy.com'
+                    ]
+                ],
+                [
+                    'type' => 'alias',
+                    'match' => 'is',
+                    'values' => [
+                        'ebay@johndoe.anonaddy.com'
+                    ]
+                ]
+            ],
+            'actions' => [
+                [
+                    'type' => 'subject',
+                    'value' => 'New Subject!'
+                ],
+            ],
+            'operator' => 'AND',
+        ]);
+
+        $alias = factory(Alias::class)->create([
+            'user_id' => $this->user->id,
+            'email' => 'ebay@johndoe.'.config('anonaddy.domain'),
+            'local_part' => 'ebay',
+            'domain' => 'johndoe.'.config('anonaddy.domain'),
+        ]);
+
+        $parser = $this->getParser(base_path('tests/emails/email.eml'));
+
+        $emailData = new EmailData($parser);
+
+        $job = new ForwardEmail($alias, $emailData, $this->user->defaultRecipient);
+
+        $email = $job->build();
+
+        $this->assertEquals('Applied after', $email->subject);
+    }
+
+    /** @test */
+    public function user_can_reorder_rules()
+    {
+        $ruleOne = factory(Rule::class)->create([
+            'user_id' => $this->user->id,
+            'order' => 2
+        ]);
+
+        $ruleTwo = factory(Rule::class)->create([
+            'user_id' => $this->user->id,
+            'order' => 0
+        ]);
+
+        $ruleThree = factory(Rule::class)->create([
+            'user_id' => $this->user->id,
+            'order' => 1
+        ]);
+
+        $response = $this->json('POST', '/api/v1/reorder-rules/', [
+            'ids' => [
+                $ruleOne->id,
+                $ruleTwo->id,
+                $ruleThree->id
+            ]
+        ]);
+
+        $this->assertEquals(0, $ruleOne->refresh()->order);
+        $this->assertEquals(1, $ruleTwo->refresh()->order);
+        $this->assertEquals(2, $ruleThree->refresh()->order);
+        $response->assertStatus(200);
+    }
+
+    protected function getParser($file)
+    {
+        $parser = new Parser;
+
+        // Fix some edge cases in from name e.g. "\" John Doe \"" <johndoe@example.com>
+        $parser->addMiddleware(function ($mimePart, $next) {
+            $part = $mimePart->getPart();
+
+            if (isset($part['headers']['from'])) {
+                $value = $part['headers']['from'];
+                $value = (is_array($value)) ? $value[0] : $value;
+
+                try {
+                    $from = collect(mailparse_rfc822_parse_addresses($value));
+
+                    if ($from->count() > 1) {
+                        $part['headers']['from'] = $from->filter(function ($f) {
+                            return filter_var($f['address'], FILTER_VALIDATE_EMAIL);
+                        })->map(function ($f) {
+                            return $f['display'] . ' <' . $f['address'] . '>';
+                        })->first();
+
+                        $mimePart->setPart($part);
+                    }
+                } catch (\Exception $e) {
+                    $part['headers']['from'] = str_replace("\\\"", "", $part['headers']['from']);
+                    $part['headers']['from'] = str_replace("\\", "", $part['headers']['from']);
+
+                    $mimePart->setPart($part);
+                }
+            }
+
+            if (isset($part['headers']['reply-to'])) {
+                $value = $part['headers']['reply-to'];
+                $value = (is_array($value)) ? $value[0] : $value;
+
+                try {
+                    mailparse_rfc822_parse_addresses($value);
+                } catch (\Exception $e) {
+                    $part['headers']['reply-to'] = '<'.Str::afterLast($part['headers']['reply-to'], '<');
+
+                    $mimePart->setPart($part);
+                }
+            }
+
+            return $next($mimePart);
+        });
+
+        if ($file == 'stream') {
+            $fd = fopen('php://stdin', 'r');
+            $this->rawEmail = '';
+            while (!feof($fd)) {
+                $this->rawEmail .= fread($fd, 1024);
+            }
+            fclose($fd);
+            $parser->setText($this->rawEmail);
+        } else {
+            $parser->setPath($file);
+        }
+        return $parser;
+    }
+}

Some files were not shown because too many files changed in this diff