Skip to content

Commit

Permalink
Merge pull request #97 from appoly/releases/5.1.0
Browse files Browse the repository at this point in the history
v5.1.0 - Storing of email attachments, and handle pruning of old mails via command
  • Loading branch information
Nathanjms authored Dec 23, 2024
2 parents 15f613f + b756beb commit fb897a4
Show file tree
Hide file tree
Showing 9 changed files with 289 additions and 28 deletions.
75 changes: 73 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,18 @@
</p>

<p align="center">
<img width="1080" height="auto" src="https://www.appoly.co.uk/app/uploads/2024/03/Screenshot-2024-03-01-at-16.38.06.png">
<!-- TODO: Get a working image/gif for this section -->
<!-- <img width="1080" height="auto" src="https://www.appoly.co.uk/app/uploads/2024/03/Screenshot-2024-03-01-at-16.38.06.png"> -->
</p>

## Features

- Outgoing Email Capture: Intercept outgoing emails from your Laravel application seamlessly.
- Tailwind UI: Enjoy a sleek and responsive user interface crafted with Tailwind CSS.
- Email Viewing: Easily view captured emails within the Mail Web dashboard.
- Shareable Links: Generate shareable links for email previews, facilitating collaboration and debugging.
- Search Functionality: Quickly search through your emails to find the information you need.


## Installation

Use the package manager [composer](https://getcomposer.org/) to install Mail Web.
Expand All @@ -31,6 +32,8 @@ composer require appoly/mail-web

## Usage

### Setup

Run the migration

```bash
Expand All @@ -50,6 +53,7 @@ php artisan vendor:publish --tag=mailweb-config --force
```

For ease, you can publish the assets by adding the following to your composer.json

```json
"post-update-cmd": [
"@php artisan vendor:publish --tag=mailweb-public --force"
Expand Down Expand Up @@ -103,13 +107,80 @@ To view emails then go to
{url}\mailweb
```

### Limiting the number of stored emails

The number of emails stored is handled via a command that must be setup to run. You can choose how often this needs to run according to how many emails you receive. Below, we have showed it being set up to run daily.

The remaining number is customisable via the `MAILWEB_LIMIT` config variable, which you can specify in your `.env`, or the default of 30 will be used.

The recommended place to schedule commands is in `routes/console.php`:

```php
// routes/console.php
use Illuminate\Support\Facades\Schedule;

// ... Your other commands here
Schedule::command('mailweb:prune')->daily();
```

Or on older laravel versions which have been upgraded manually, you may still be using `app/Console/Kernel.php`:

```php
protected function schedule(Schedule $schedule)
{
// ... Your other commands here
$schedule->command('mailweb:prune')->dailyAt('01:00');
}
```

### Storing Attachments on a disk (eg. s3)

To store attachments on a disk, the config variable `MAILWEB_ATTACHMENTS.DISK` must be set to the disk name, which should exist in your app's `config/filesystems.php` file. This is set via a `.env` variable `MAILWEB_ATTACHMENTS_DISK`.

Eg. If you have a disk called `s3` setup, then adding `MAILWEB_ATTACHMENTS_DISK=s3` to your `.env` file will store attachments on the `s3` disk.

The default path is `/mailweb/attachments/...`, and this can be customised but updating the `MAILWEB_ATTACHMENTS_PATH` env variable to whatever you wish.

When mails are deleted, the attachments will be deleted as well if the disk and path have remained unchanged from when the attachment was created.

## Migrating to v5

If you previously used MailWeb you will notice a new archived table. This is because we have changed to data structure making it easier to pull out the email data we need rather than storing the whole email object. We are working on a command to migrate any stored emails over but for the time being these emails will no longer be viewable.

## Contributing

Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change.

### Setup

There are multiple ways to set up a local composer project, one of which is as follows:

1. Clone this repository
2. Run `composer install`
3. Note the path to the directory
4. Go to another php/Laravel project and add the following items to your composer.json:

```php
"repositories": [
{
"type": "path",
"url": "../path/to/MailWeb",
"options": {
"symlink": true
}
}
],
```

5. Change the require section with `@dev` for the package:

```php
"require": {
"appoly/mail-web": "@dev"
```

6. Run `composer update` in this project, and it should now be linked to the dev version of MailWeb

## License

[MIT](https://choosealicense.com/licenses/mit/)
4 changes: 4 additions & 0 deletions config/config.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,8 @@
'APP_NAME' => env('MAILWEB_RETURN_APP_NAME'),
'APP_URL' => env('MAILWEB_RETURN_APP_URL'),
],
'MAILWEB_ATTACHMENTS' => [
'DISK' => env('MAILWEB_ATTACHMENTS_DISK'),
'PATH' => env('MAILWEB_ATTACHMENTS_PATH', 'mailweb/attachments'),
],
];
12 changes: 12 additions & 0 deletions resources/views/livewire/download-attachment.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<div>
<x-mailweb::button type="button" wire:click="download" wire:loading.attr="disabled" wire:loading.class="opacity-50">
<span wire:loading.remove>{{ $attachment->name }}</span>
<span wire:loading>Downloading...</span>
</x-mailweb::button>
@if ($errorMessage)
<div class="text-sm text-red-600">
{{ $errorMessage }}
</div>
@endif

</div>
28 changes: 19 additions & 9 deletions resources/views/livewire/email-view.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
@if (!$this->showOnly)
<div class="flex justify-between gap-4 rounded-t-lg">
<div class="flex gap-4">
<x-mailweb::toolbar-button wire:click="toggleEmailView" @class(['border-b-2 border-b-appoly-red/50' => $this->mode === 'email'])>
<x-mailweb::toolbar-button wire:click="toggleEmailView" @class([
'border-b-2 border-b-appoly-red/50' => $this->mode === 'email',
])>
<div class="flex gap-2">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round"
Expand All @@ -15,7 +17,9 @@
</svg> <span>Email</span>
</div>
</x-mailweb::toolbar-button>
<x-mailweb::toolbar-button wire:click="toggleSource" @class(['border-b-2 border-b-appoly-red/50' => $this->mode === 'source'])>
<x-mailweb::toolbar-button wire:click="toggleSource" @class([
'border-b-2 border-b-appoly-red/50' => $this->mode === 'source',
])>
<div class="flex gap-2">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round"
Expand All @@ -27,7 +31,9 @@
<span>Source</span>
</div>
</x-mailweb::toolbar-button>
<x-mailweb::toolbar-button wire:click="toggleText" @class(['border-b-2 border-b-appoly-red/50' => $this->mode === 'text'])>
<x-mailweb::toolbar-button wire:click="toggleText" @class([
'border-b-2 border-b-appoly-red/50' => $this->mode === 'text',
])>
<div class="flex gap-2">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round"
Expand Down Expand Up @@ -118,12 +124,16 @@ class="text-sm text-gray-700 dark:text-gray-300">({{ $to['address'] }})</span>
<span class="mb-2 text-sm font-bold text-gray-700 dark:text-gray-300">Attachments:</span>
<div class="flex gap-4 ">
@foreach ($email->attachments as $attachment)
<div
class="flex items-center justify-center w-24 h-10 bg-gray-200 rounded-lg dark:bg-gray-800">
<span class="text-sm text-gray-500 dark:text-gray-300">
{{ $attachment->name }}
</span>
</div>
@if ($attachment->path)
<livewire:mailweb::download-attachment :attachment="$attachment" :email="$email" :emailId="$email->id" :attachmentId="$attachment->id" />
@else
<div
class="flex items-center justify-center w-24 h-10 bg-gray-200 rounded-lg dark:bg-gray-800">
<span class="text-sm text-gray-500 dark:text-gray-300">
{{ $attachment->name }}
</span>
</div>
@endif
@endforeach
</div>
</div>
Expand Down
67 changes: 67 additions & 0 deletions src/Console/Commands/PruneMailwebMails.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<?php

namespace Appoly\MailWeb\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Appoly\MailWeb\Http\Models\MailwebEmail;

class PruneMailwebMails extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'mailweb:prune {remaining?}';

/**
* The console command description.
*
* @var string
*/
protected $description = 'Prune old mailweb emails up to the limit passed, or the default set in config';

/**
* Execute the console command.
*/
public function handle(): void
{
// If remaining is not provided, use the config default
$remaining = $this->argument('remaining');
if ($remaining === null) {
$remaining = config('MailWeb.MAILWEB_LIMIT', 30);
}

$remaining = (int) $remaining;

$emailIdsToDeleteKeyedById = MailwebEmail::latest()
->withCount(['attachments as hasFileAttachments' => fn($q) => $q->whereNotNull('path')])
->limit(5_000) // We need a limit to use offset, and to avoid memory issues. 5k is... probably enough, as this can just run more often if not
->offset($remaining) // We keep only the amount specified, so offset by that number
->pluck('hasFileAttachments', 'id') // Key of email ID, val of count of attachments
->toArray();

$emailIdsWithAttachments = array_keys(array_filter($emailIdsToDeleteKeyedById, fn($count) => $count > 0));

Log::info('Pruning ' . count($emailIdsToDeleteKeyedById) . ' emails, of which ' . count($emailIdsWithAttachments) . ' have attachments');

// Attachment cleanup needs to be done slowly
if (count($emailIdsToDeleteKeyedById) > 0) {
dispatch(function () use ($emailIdsWithAttachments) {
// Chunk into batches of 100 for deletion
$storageDisk = config('MailWeb.MAILWEB_ATTACHMENTS.DISK');
if ($storageDisk) {
$basePath = config('MailWeb.MAILWEB_ATTACHMENTS.PATH');

foreach ($emailIdsWithAttachments as $emailId) {
Storage::disk($storageDisk)->deleteDirectory($basePath . '/' . $emailId);
}
}
});
}

MailwebEmail::whereIn('id', array_keys($emailIdsToDeleteKeyedById))->delete();
}
}
2 changes: 2 additions & 0 deletions src/Http/Controllers/MailWebController.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

namespace Appoly\MailWeb\Http\Controllers;

use Appoly\MailWeb\Http\Models\MailwebEmailAttachment;
use Illuminate\Support\Facades\Gate;
use Appoly\MailWeb\Http\Models\MailwebEmail;
use Illuminate\Support\Facades\Storage;

class MailWebController
{
Expand Down
52 changes: 35 additions & 17 deletions src/Http/Listeners/MailWebListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use Illuminate\Support\Facades\DB;
use Illuminate\Mail\Events\MessageSending;
use Appoly\MailWeb\Http\Models\MailwebEmail;
use Illuminate\Support\Facades\Storage;

class MailWebListener
{
Expand All @@ -23,7 +24,7 @@ public function __construct()
*/
public function handle(MessageSending $event): void
{
if (! config('MailWeb.MAILWEB_ENABLED')) {
if (!config('MailWeb.MAILWEB_ENABLED')) {
return;
}

Expand All @@ -40,15 +41,41 @@ public function handle(MessageSending $event): void
]);

foreach ($event->message->getAttachments() as $attachment) {
$mailwebEmail->attachments()->create([
'name' => $attachment->getFilename(),
'path' => null,
]);
}
if ($attachment instanceof \Symfony\Component\Mime\Part\DataPart) {
// Extract attachment details
$fileName = $attachment->getFilename();
$fileContent = $attachment->getBody();
$mimeType = $attachment->getMediaType() . '/' . $attachment->getMediaSubtype();

// Store the original name regardless of whether we end up backing up the file
$attachment = $mailwebEmail->attachments()->create([
'name' => $fileName,
'path' => null,
]);

try {
$storageDisk = config('MailWeb.MAILWEB_ATTACHMENTS.DISK');
// If the config is enabled, we store the file
if ($storageDisk) {
$path = config('MailWeb.MAILWEB_ATTACHMENTS.PATH') . '/' . $attachment->mailweb_email_id . '/' . $attachment->id;
Storage::disk($storageDisk)->put(
path: $path,
contents: $fileContent,
options: ['ContentType' => $mimeType]
);

// });
$attachment->update([
'path' => $path,
]);
}
} catch (\Throwable $e) {
// We don't want to fail the entire process, so just log the error and move on
report($e);
return;
}

$this->prune();
}
}
}

private function getAddresses(array $addresses): array
Expand All @@ -60,13 +87,4 @@ private function getAddresses(array $addresses): array
];
})->toArray();
}

private function prune(): void
{
if ((int) config('mailweb.limit') === 0) {
return;
}

MailwebEmail::oldest()->limit((int) config('mailweb.limit'))->delete();
}
}
Loading

0 comments on commit fb897a4

Please sign in to comment.