add: full multi-tenancy control

This commit is contained in:
Cauê Faleiros
2026-02-02 15:31:15 -03:00
commit c6ec92802b
1711 changed files with 258106 additions and 0 deletions

View File

@@ -0,0 +1,28 @@
{
"name": "krayin/laravel-email",
"license": "MIT",
"authors": [
{
"name": "Jitendra Singh",
"email": "jitendra@webkul.com"
}
],
"require": {
"krayin/laravel-contact": "^1.0",
"krayin/laravel-core": "^1.0"
},
"autoload": {
"psr-4": {
"Webkul\\Email\\": "src/"
}
},
"extra": {
"laravel": {
"providers": [
"Webkul\\Email\\Providers\\EmailServiceProvider"
],
"aliases": {}
}
},
"minimum-stability": "dev"
}

View File

@@ -0,0 +1,48 @@
<?php
namespace Webkul\Email\Console\Commands;
use Illuminate\Console\Command;
use Webkul\Email\InboundEmailProcessor\Contracts\InboundEmailProcessor;
class ProcessInboundEmails extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'inbound-emails:process';
/**
* The console command description.
*
* @var string
*/
protected $description = 'This command will process the incoming emails from the mail server.';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct(
protected InboundEmailProcessor $inboundEmailProcessor
) {
parent::__construct();
}
/**
* Handle.
*
* @return void
*/
public function handle()
{
$this->info('Processing the incoming emails.');
$this->inboundEmailProcessor->processMessagesFromAllFolders();
$this->info('Incoming emails processed successfully.');
}
}

View File

@@ -0,0 +1,5 @@
<?php
namespace Webkul\Email\Contracts;
interface Attachment {}

View File

@@ -0,0 +1,5 @@
<?php
namespace Webkul\Email\Contracts;
interface Email {}

View File

@@ -0,0 +1,58 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('emails', function (Blueprint $table) {
$table->increments('id');
$table->string('subject')->nullable();
$table->string('source');
$table->string('user_type');
$table->string('name')->nullable();
$table->text('reply')->nullable();
$table->boolean('is_read')->default(0);
$table->json('folders')->nullable();
$table->json('from')->nullable();
$table->json('sender')->nullable();
$table->json('reply_to')->nullable();
$table->json('cc')->nullable();
$table->json('bcc')->nullable();
$table->string('unique_id')->nullable()->unique();
$table->string('message_id')->unique();
$table->json('reference_ids')->nullable();
$table->integer('person_id')->unsigned()->nullable();
$table->foreign('person_id')->references('id')->on('persons')->onDelete('set null');
$table->integer('lead_id')->unsigned()->nullable();
$table->foreign('lead_id')->references('id')->on('leads')->onDelete('set null');
$table->timestamps();
});
Schema::table('emails', function (Blueprint $table) {
$table->integer('parent_id')->unsigned()->nullable();
$table->foreign('parent_id')->references('id')->on('emails')->onDelete('cascade');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('emails');
}
};

View File

@@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('email_attachments', function (Blueprint $table) {
$table->increments('id');
$table->string('name')->nullable();
$table->string('path');
$table->integer('size')->nullable();
$table->string('content_type')->nullable();
$table->string('content_id')->nullable();
$table->integer('email_id')->unsigned();
$table->foreign('email_id')->references('id')->on('emails')->onDelete('cascade');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('email_attachments');
}
};

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('email_tags', function (Blueprint $table) {
$table->integer('tag_id')->unsigned();
$table->foreign('tag_id')->references('id')->on('tags')->onDelete('cascade');
$table->integer('email_id')->unsigned();
$table->foreign('email_id')->references('id')->on('emails')->onDelete('cascade');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('email_tags');
}
};

View File

@@ -0,0 +1,46 @@
<?php
namespace Webkul\Email\Enums;
enum SupportedFolderEnum: string
{
/**
* Inbox.
*/
case INBOX = 'inbox';
/**
* Important.
*/
case IMPORTANT = 'important';
/**
* Starred.
*/
case STARRED = 'starred';
/**
* Draft.
*/
case DRAFT = 'draft';
/**
* Outbox.
*/
case OUTBOX = 'outbox';
/**
* Sent.
*/
case SENT = 'sent';
/**
* Spam.
*/
case SPAM = 'spam';
/**
* Trash.
*/
case TRASH = 'trash';
}

View File

@@ -0,0 +1,108 @@
<?php
namespace Webkul\Email\Helpers;
class Attachment
{
/**
* Content.
*
* @var File Content
*/
private $content = null;
/**
* Create an helper instance
*/
public function __construct(
public $filename,
public $contentType,
public $stream,
public $contentDisposition = 'attachment',
public $contentId = '',
public $headers = []
) {}
/**
* Retrieve the attachment filename.
*
* @return string
*/
public function getFilename()
{
return $this->filename;
}
/**
* Retrieve the attachment content type.
*
* @return string
*/
public function getContentType()
{
return $this->contentType;
}
/**
* Retrieve the attachment content disposition.
*
* @return string
*/
public function getContentDisposition()
{
return $this->contentDisposition;
}
/**
* Retrieve the attachment content ID.
*
* @return string
*/
public function getContentID()
{
return $this->contentId;
}
/**
* Retrieve the attachment headers.
*
* @return string
*/
public function getHeaders()
{
return $this->headers;
}
/**
* Read the contents a few bytes at a time until completed.
*
* Once read to completion, it always returns false.
*
* @param int $bytes
* @return string
*/
public function read($bytes = 2082)
{
return feof($this->stream) ? false : fread($this->stream, $bytes);
}
/**
* Retrieve the file content in one go.
*
* Once you retrieve the content you cannot use MimeMailParser_attachment::read().
*
* @return string
*/
public function getContent()
{
if ($this->content === null) {
fseek($this->stream, 0);
while (($buf = $this->read()) !== false) {
$this->content .= $buf;
}
}
return $this->content;
}
}

View File

@@ -0,0 +1,353 @@
<?php
namespace Webkul\Email\Helpers;
use Webkul\Email\Helpers\Contracts\CharsetManager;
class Charset implements CharsetManager
{
/**
* Charset aliases.
*
* @var array
*/
private $charsetAlias = [
'ascii' => 'us-ascii',
'us-ascii' => 'us-ascii',
'ansi_x3.4-1968' => 'us-ascii',
'646' => 'us-ascii',
'iso-8859-1' => 'ISO-8859-1',
'iso-8859-2' => 'ISO-8859-2',
'iso-8859-3' => 'ISO-8859-3',
'iso-8859-4' => 'ISO-8859-4',
'iso-8859-5' => 'ISO-8859-5',
'iso-8859-6' => 'ISO-8859-6',
'iso-8859-6-i' => 'ISO-8859-6-I',
'iso-8859-6-e' => 'ISO-8859-6-E',
'iso-8859-7' => 'ISO-8859-7',
'iso-8859-8' => 'ISO-8859-8',
'iso-8859-8-i' => 'ISO-8859-8-I',
'iso-8859-8-e' => 'ISO-8859-8-E',
'iso-8859-9' => 'ISO-8859-9',
'iso-8859-10' => 'ISO-8859-10',
'iso-8859-11' => 'ISO-8859-11',
'iso-8859-13' => 'ISO-8859-13',
'iso-8859-14' => 'ISO-8859-14',
'iso-8859-15' => 'ISO-8859-15',
'iso-8859-16' => 'ISO-8859-16',
'iso-ir-111' => 'ISO-IR-111',
'iso-2022-cn' => 'ISO-2022-CN',
'iso-2022-cn-ext' => 'ISO-2022-CN',
'iso-2022-kr' => 'ISO-2022-KR',
'iso-2022-jp' => 'ISO-2022-JP',
'utf-16be' => 'UTF-16BE',
'utf-16le' => 'UTF-16LE',
'utf-16' => 'UTF-16',
'windows-1250' => 'windows-1250',
'windows-1251' => 'windows-1251',
'windows-1252' => 'windows-1252',
'windows-1253' => 'windows-1253',
'windows-1254' => 'windows-1254',
'windows-1255' => 'windows-1255',
'windows-1256' => 'windows-1256',
'windows-1257' => 'windows-1257',
'windows-1258' => 'windows-1258',
'ibm866' => 'IBM866',
'ibm850' => 'IBM850',
'ibm852' => 'IBM852',
'ibm855' => 'IBM855',
'ibm857' => 'IBM857',
'ibm862' => 'IBM862',
'ibm864' => 'IBM864',
'utf-8' => 'UTF-8',
'utf-7' => 'UTF-7',
'shift_jis' => 'Shift_JIS',
'big5' => 'Big5',
'euc-jp' => 'EUC-JP',
'euc-kr' => 'EUC-KR',
'gb2312' => 'GB2312',
'gb18030' => 'gb18030',
'viscii' => 'VISCII',
'koi8-r' => 'KOI8-R',
'koi8_r' => 'KOI8-R',
'cskoi8r' => 'KOI8-R',
'koi' => 'KOI8-R',
'koi8' => 'KOI8-R',
'koi8-u' => 'KOI8-U',
'tis-620' => 'TIS-620',
't.61-8bit' => 'T.61-8bit',
'hz-gb-2312' => 'HZ-GB-2312',
'big5-hkscs' => 'Big5-HKSCS',
'gbk' => 'gbk',
'cns11643' => 'x-euc-tw',
'x-imap4-modified-utf7' => 'x-imap4-modified-utf7',
'x-euc-tw' => 'x-euc-tw',
'x-mac-ce' => 'x-mac-ce',
'x-mac-turkish' => 'x-mac-turkish',
'x-mac-greek' => 'x-mac-greek',
'x-mac-icelandic' => 'x-mac-icelandic',
'x-mac-croatian' => 'x-mac-croatian',
'x-mac-romanian' => 'x-mac-romanian',
'x-mac-cyrillic' => 'x-mac-cyrillic',
'x-mac-ukrainian' => 'x-mac-cyrillic',
'x-mac-hebrew' => 'x-mac-hebrew',
'x-mac-arabic' => 'x-mac-arabic',
'x-mac-farsi' => 'x-mac-farsi',
'x-mac-devanagari' => 'x-mac-devanagari',
'x-mac-gujarati' => 'x-mac-gujarati',
'x-mac-gurmukhi' => 'x-mac-gurmukhi',
'armscii-8' => 'armscii-8',
'x-viet-tcvn5712' => 'x-viet-tcvn5712',
'x-viet-vps' => 'x-viet-vps',
'iso-10646-ucs-2' => 'UTF-16BE',
'x-iso-10646-ucs-2-be' => 'UTF-16BE',
'x-iso-10646-ucs-2-le' => 'UTF-16LE',
'x-user-defined' => 'x-user-defined',
'x-johab' => 'x-johab',
'latin1' => 'ISO-8859-1',
'iso_8859-1' => 'ISO-8859-1',
'iso8859-1' => 'ISO-8859-1',
'iso8859-2' => 'ISO-8859-2',
'iso8859-3' => 'ISO-8859-3',
'iso8859-4' => 'ISO-8859-4',
'iso8859-5' => 'ISO-8859-5',
'iso8859-6' => 'ISO-8859-6',
'iso8859-7' => 'ISO-8859-7',
'iso8859-8' => 'ISO-8859-8',
'iso8859-9' => 'ISO-8859-9',
'iso8859-10' => 'ISO-8859-10',
'iso8859-11' => 'ISO-8859-11',
'iso8859-13' => 'ISO-8859-13',
'iso8859-14' => 'ISO-8859-14',
'iso8859-15' => 'ISO-8859-15',
'iso_8859-1:1987' => 'ISO-8859-1',
'iso-ir-100' => 'ISO-8859-1',
'l1' => 'ISO-8859-1',
'ibm819' => 'ISO-8859-1',
'cp819' => 'ISO-8859-1',
'csisolatin1' => 'ISO-8859-1',
'latin2' => 'ISO-8859-2',
'iso_8859-2' => 'ISO-8859-2',
'iso_8859-2:1987' => 'ISO-8859-2',
'iso-ir-101' => 'ISO-8859-2',
'l2' => 'ISO-8859-2',
'csisolatin2' => 'ISO-8859-2',
'latin3' => 'ISO-8859-3',
'iso_8859-3' => 'ISO-8859-3',
'iso_8859-3:1988' => 'ISO-8859-3',
'iso-ir-109' => 'ISO-8859-3',
'l3' => 'ISO-8859-3',
'csisolatin3' => 'ISO-8859-3',
'latin4' => 'ISO-8859-4',
'iso_8859-4' => 'ISO-8859-4',
'iso_8859-4:1988' => 'ISO-8859-4',
'iso-ir-110' => 'ISO-8859-4',
'l4' => 'ISO-8859-4',
'csisolatin4' => 'ISO-8859-4',
'cyrillic' => 'ISO-8859-5',
'iso_8859-5' => 'ISO-8859-5',
'iso_8859-5:1988' => 'ISO-8859-5',
'iso-ir-144' => 'ISO-8859-5',
'csisolatincyrillic' => 'ISO-8859-5',
'arabic' => 'ISO-8859-6',
'iso_8859-6' => 'ISO-8859-6',
'iso_8859-6:1987' => 'ISO-8859-6',
'iso-ir-127' => 'ISO-8859-6',
'ecma-114' => 'ISO-8859-6',
'asmo-708' => 'ISO-8859-6',
'csisolatinarabic' => 'ISO-8859-6',
'csiso88596i' => 'ISO-8859-6-I',
'csiso88596e' => 'ISO-8859-6-E',
'greek' => 'ISO-8859-7',
'greek8' => 'ISO-8859-7',
'sun_eu_greek' => 'ISO-8859-7',
'iso_8859-7' => 'ISO-8859-7',
'iso_8859-7:1987' => 'ISO-8859-7',
'iso-ir-126' => 'ISO-8859-7',
'elot_928' => 'ISO-8859-7',
'ecma-118' => 'ISO-8859-7',
'csisolatingreek' => 'ISO-8859-7',
'hebrew' => 'ISO-8859-8',
'iso_8859-8' => 'ISO-8859-8',
'visual' => 'ISO-8859-8',
'iso_8859-8:1988' => 'ISO-8859-8',
'iso-ir-138' => 'ISO-8859-8',
'csisolatinhebrew' => 'ISO-8859-8',
'csiso88598i' => 'ISO-8859-8-I',
'iso-8859-8i' => 'ISO-8859-8-I',
'logical' => 'ISO-8859-8-I',
'csiso88598e' => 'ISO-8859-8-E',
'latin5' => 'ISO-8859-9',
'iso_8859-9' => 'ISO-8859-9',
'iso_8859-9:1989' => 'ISO-8859-9',
'iso-ir-148' => 'ISO-8859-9',
'l5' => 'ISO-8859-9',
'csisolatin5' => 'ISO-8859-9',
'unicode-1-1-utf-8' => 'UTF-8',
'utf8' => 'UTF-8',
'x-sjis' => 'Shift_JIS',
'shift-jis' => 'Shift_JIS',
'ms_kanji' => 'Shift_JIS',
'csshiftjis' => 'Shift_JIS',
'windows-31j' => 'Shift_JIS',
'cp932' => 'Shift_JIS',
'sjis' => 'Shift_JIS',
'cseucpkdfmtjapanese' => 'EUC-JP',
'x-euc-jp' => 'EUC-JP',
'csiso2022jp' => 'ISO-2022-JP',
'iso-2022-jp-2' => 'ISO-2022-JP',
'csiso2022jp2' => 'ISO-2022-JP',
'csbig5' => 'Big5',
'cn-big5' => 'Big5',
'x-x-big5' => 'Big5',
'zh_tw-big5' => 'Big5',
'cseuckr' => 'EUC-KR',
'ks_c_5601-1987' => 'EUC-KR',
'iso-ir-149' => 'EUC-KR',
'ks_c_5601-1989' => 'EUC-KR',
'ksc_5601' => 'EUC-KR',
'ksc5601' => 'EUC-KR',
'korean' => 'EUC-KR',
'csksc56011987' => 'EUC-KR',
'5601' => 'EUC-KR',
'windows-949' => 'EUC-KR',
'gb_2312-80' => 'GB2312',
'iso-ir-58' => 'GB2312',
'chinese' => 'GB2312',
'csiso58gb231280' => 'GB2312',
'csgb2312' => 'GB2312',
'zh_cn.euc' => 'GB2312',
'gb_2312' => 'GB2312',
'x-cp1250' => 'windows-1250',
'x-cp1251' => 'windows-1251',
'x-cp1252' => 'windows-1252',
'x-cp1253' => 'windows-1253',
'x-cp1254' => 'windows-1254',
'x-cp1255' => 'windows-1255',
'x-cp1256' => 'windows-1256',
'x-cp1257' => 'windows-1257',
'x-cp1258' => 'windows-1258',
'windows-874' => 'windows-874',
'ibm874' => 'windows-874',
'dos-874' => 'windows-874',
'macintosh' => 'macintosh',
'x-mac-roman' => 'macintosh',
'mac' => 'macintosh',
'csmacintosh' => 'macintosh',
'cp866' => 'IBM866',
'cp-866' => 'IBM866',
'866' => 'IBM866',
'csibm866' => 'IBM866',
'cp850' => 'IBM850',
'850' => 'IBM850',
'csibm850' => 'IBM850',
'cp852' => 'IBM852',
'852' => 'IBM852',
'csibm852' => 'IBM852',
'cp855' => 'IBM855',
'855' => 'IBM855',
'csibm855' => 'IBM855',
'cp857' => 'IBM857',
'857' => 'IBM857',
'csibm857' => 'IBM857',
'cp862' => 'IBM862',
'862' => 'IBM862',
'csibm862' => 'IBM862',
'cp864' => 'IBM864',
'864' => 'IBM864',
'csibm864' => 'IBM864',
'ibm-864' => 'IBM864',
't.61' => 'T.61-8bit',
'iso-ir-103' => 'T.61-8bit',
'csiso103t618bit' => 'T.61-8bit',
'x-unicode-2-0-utf-7' => 'UTF-7',
'unicode-2-0-utf-7' => 'UTF-7',
'unicode-1-1-utf-7' => 'UTF-7',
'csunicode11utf7' => 'UTF-7',
'csunicode' => 'UTF-16BE',
'csunicode11' => 'UTF-16BE',
'iso-10646-ucs-basic' => 'UTF-16BE',
'csunicodeascii' => 'UTF-16BE',
'iso-10646-unicode-latin1' => 'UTF-16BE',
'csunicodelatin1' => 'UTF-16BE',
'iso-10646' => 'UTF-16BE',
'iso-10646-j-1' => 'UTF-16BE',
'latin6' => 'ISO-8859-10',
'iso-ir-157' => 'ISO-8859-10',
'l6' => 'ISO-8859-10',
'csisolatin6' => 'ISO-8859-10',
'iso_8859-15' => 'ISO-8859-15',
'csisolatin9' => 'ISO-8859-15',
'l9' => 'ISO-8859-15',
'ecma-cyrillic' => 'ISO-IR-111',
'csiso111ecmacyrillic' => 'ISO-IR-111',
'csiso2022kr' => 'ISO-2022-KR',
'csviscii' => 'VISCII',
'zh_tw-euc' => 'x-euc-tw',
'iso88591' => 'ISO-8859-1',
'iso88592' => 'ISO-8859-2',
'iso88593' => 'ISO-8859-3',
'iso88594' => 'ISO-8859-4',
'iso88595' => 'ISO-8859-5',
'iso88596' => 'ISO-8859-6',
'iso88597' => 'ISO-8859-7',
'iso88598' => 'ISO-8859-8',
'iso88599' => 'ISO-8859-9',
'iso885910' => 'ISO-8859-10',
'iso885911' => 'ISO-8859-11',
'iso885912' => 'ISO-8859-12',
'iso885913' => 'ISO-8859-13',
'iso885914' => 'ISO-8859-14',
'iso885915' => 'ISO-8859-15',
'tis620' => 'TIS-620',
'cp1250' => 'windows-1250',
'cp1251' => 'windows-1251',
'cp1252' => 'windows-1252',
'cp1253' => 'windows-1253',
'cp1254' => 'windows-1254',
'cp1255' => 'windows-1255',
'cp1256' => 'windows-1256',
'cp1257' => 'windows-1257',
'cp1258' => 'windows-1258',
'x-gbk' => 'gbk',
'windows-936' => 'gbk',
'ansi-1251' => 'windows-1251',
];
/**
* Decode the string from charset.
*
* @param string $encodedString
* @param string $charset
* @return string
*/
public function decodeCharset($encodedString, $charset)
{
if (strtolower($charset) == 'utf-8' || strtolower($charset) == 'us-ascii') {
return $encodedString;
}
try {
return iconv($this->getCharsetAlias($charset), 'UTF-8//TRANSLIT', $encodedString);
} catch (\Exception $e) {
return iconv($this->getCharsetAlias($charset), 'UTF-8//IGNORE', $encodedString);
}
}
/**
* Get charset alias.
*
* @param string $charset.
* @return string
*/
public function getCharsetAlias($charset)
{
$charset = strtolower($charset);
if (array_key_exists($charset, $this->charsetAlias)) {
return $this->charsetAlias[$charset];
}
return null;
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace Webkul\Email\Helpers\Contracts;
interface CharsetManager
{
/**
* Decode the string from Charset.
*
* @return string
*/
public function decodeCharset($encodedString, $charset);
/**
* Get charset alias.
*
* @return string
*/
public function getCharsetAlias($charset);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,888 @@
<?php
namespace Webkul\Email\Helpers;
use Webkul\Email\Helpers\Contracts\CharsetManager;
class Parser
{
/**
* Resource.
*/
public $resource;
/**
* A file pointer to email.
*/
public $stream;
/**
* Data.
*/
public $data;
/**
* Container.
*/
public $container;
/**
* Entity.
*/
public $entity;
/**
* Files.
*/
public $files;
/**
* Parts of an email.
*/
public $parts;
/**
* Charset manager object.
*/
public $charset;
/**
* Create a new instance.
*
* @return void
*/
public function __construct(?CharsetManager $charset = null)
{
if (is_null($charset)) {
$charset = new Charset;
}
$this->charset = $charset;
}
/**
* Free the held resouces.
*
* @return void
*/
public function __destruct()
{
// clear the email file resource
if (is_resource($this->stream)) {
fclose($this->stream);
}
// clear the mail parse resource
if (is_resource($this->resource)) {
mailparse_msg_free($this->resource);
}
}
/**
* Set the file path we use to get the email text.
*
* @param string $path
* @return object
*/
public function setPath($path)
{
$this->resource = mailparse_msg_parse_file($path);
$this->stream = fopen($path, 'r');
$this->parse();
return $this;
}
/**
* Set the stream resource we use to get the email text.
*
* @return object
*/
public function setStream($stream)
{
// streams have to be cached to file first
$meta = @stream_get_meta_data($stream);
if (
! $meta
|| ! $meta['mode']
|| $meta['mode'][0] != 'r'
|| $meta['eof']
) {
throw new \Exception(
'setStream() expects parameter stream to be readable stream resource.'
);
}
$tmp_fp = tmpfile();
if ($tmp_fp) {
while (! feof($stream)) {
fwrite($tmp_fp, fread($stream, 2028));
}
fseek($tmp_fp, 0);
$this->stream = &$tmp_fp;
} else {
throw new \Exception(
'Could not create temporary files for attachments. Your tmp directory may be un-writable by PHP.'
);
}
fclose($stream);
$this->resource = mailparse_msg_create();
// parses the message incrementally (low memory usage but slower)
while (! feof($this->stream)) {
mailparse_msg_parse($this->resource, fread($this->stream, 2082));
}
$this->parse();
return $this;
}
/**
* Set the email text.
*
* @return object
*/
public function setText($data)
{
$this->resource = \mailparse_msg_create();
// does not parse incrementally, fast memory hog might explode
mailparse_msg_parse($this->resource, $data);
$this->data = $data;
$this->parse();
return $this;
}
/**
* Parse the message into parts.
*
* @return void
*/
private function parse()
{
$structure = mailparse_msg_get_structure($this->resource);
$headerType = (stripos($this->data, 'Content-Language:') !== false) ? 'Content-Language:' : 'Content-Type:';
if (count($structure) == 1) {
$tempParts = explode(PHP_EOL, $this->data);
foreach ($tempParts as $key => $part) {
if (stripos($part, $headerType) !== false) {
break;
}
if (trim($part) == '') {
unset($tempParts[$key]);
}
}
$data = implode(PHP_EOL, $tempParts);
$this->resource = \mailparse_msg_create();
mailparse_msg_parse($this->resource, $data);
$this->data = $data;
$structure = mailparse_msg_get_structure($this->resource);
}
$this->parts = [];
foreach ($structure as $part_id) {
$part = mailparse_msg_get_part($this->resource, $part_id);
$this->parts[$part_id] = mailparse_msg_get_part_data($part);
}
}
/**
* Parse sender name.
*
* @return string
*/
public function parseSenderName()
{
if (! $fromNameParts = mailparse_rfc822_parse_addresses($this->getHeader('from'))) {
$fromNameParts = mailparse_rfc822_parse_addresses($this->getHeader('sender'));
}
return $fromNameParts[0]['display'] == $fromNameParts[0]['address']
? current(explode('@', $fromNameParts[0]['display']))
: $fromNameParts[0]['display'];
}
/**
* Parse email address.
*
* @param string $type
* @return array
*/
public function parseEmailAddress($type)
{
$emails = [];
$addresses = mailparse_rfc822_parse_addresses($this->getHeader($type));
if (count($addresses) > 1) {
foreach ($addresses as $address) {
if (filter_var($address['address'], FILTER_VALIDATE_EMAIL)) {
$emails[] = $address['address'];
}
}
} elseif ($addresses) {
$emails[] = $addresses[0]['address'];
}
return $emails;
}
/**
* Retrieve a specific email header, without charset conversion.
*
* @return string
*/
public function getRawHeader($name)
{
if (isset($this->parts[1])) {
$headers = $this->getPart('headers', $this->parts[1]);
return (isset($headers[$name])) ? $headers[$name] : false;
} else {
throw new \Exception(
'setPath() or setText() or setStream() must be called before retrieving email headers.'
);
}
}
/**
* Retrieve a specific email header.
*
* @return string
*/
public function getHeader($name)
{
$rawHeader = $this->getRawHeader($name);
if ($rawHeader === false) {
return false;
}
return $this->decodeHeader($rawHeader);
}
/**
* Retrieve all mail headers.
*
* @return array
*/
public function getHeaders()
{
if (isset($this->parts[1])) {
$headers = $this->getPart('headers', $this->parts[1]);
foreach ($headers as $name => &$value) {
if (is_array($value)) {
foreach ($value as &$v) {
$v = $this->decodeSingleHeader($v);
}
} else {
$value = $this->decodeSingleHeader($value);
}
}
return $headers;
} else {
throw new \Exception(
'setPath() or setText() or setStream() must be called before retrieving email headers.'
);
}
}
/**
* Get from name.
*
* @return string
*/
public function getFromName()
{
$headers = $this->getHeaders();
return $headers['from'];
}
/**
* Extract multipart MIME text.
*
* @return string
*/
public function extractMultipartMIMEText($part, $source, $encodingType)
{
$boundary = trim($part['content-boundary']);
$boundary = substr($boundary, strpos($boundary, '----=') + strlen('----='));
preg_match_all('/------=(3D_.*)\sContent-Type:\s(.*)\s*boundary=3D"----=(3D_.*)"/', $source, $matches);
$delimeter = array_shift($matches);
$content_delimeter = end($delimeter);
[$relations, $content_types, $boundaries] = $matches;
$messageToProcess = substr($source, stripos($source, (string) $content_delimeter) + strlen($content_delimeter));
array_unshift($boundaries, $boundary);
// Extract the text
foreach (array_reverse($boundaries) as $index => $boundary) {
$processedEmailSegments = [];
$emailSegments = explode('------='.$boundary, $messageToProcess);
// Remove empty parts
foreach ($emailSegments as $emailSegment) {
if (! empty(trim($emailSegment))) {
$processedEmailSegments[] = trim($emailSegment);
}
}
// Remove unrelated parts
array_pop($processedEmailSegments);
for ($i = 0; $i < $index; $i++) {
if (count($processedEmailSegments) > 1) {
array_shift($processedEmailSegments);
}
}
// Parse each parts for text|html content
foreach ($processedEmailSegments as $emailSegment) {
$emailSegment = quoted_printable_decode(quoted_printable_decode($emailSegment));
if (stripos($emailSegment, 'content-type: text/plain;') !== false
|| stripos($emailSegment, 'content-type: text/html;') !== false
) {
$search = 'content-transfer-encoding: '.$encodingType;
return substr($emailSegment, stripos($emailSegment, $search) + strlen($search));
}
}
}
return '';
}
/**
* Returns the email message body in the specified format.
*
* @return mixed
*/
public function getMessageBody($type = 'text')
{
$textBody = $htmlBody = $body = false;
$mime_types = [
'text'=> 'text/plain',
'text'=> 'text/plain; (error)',
'html'=> 'text/html',
];
if (in_array($type, array_keys($mime_types))) {
foreach ($this->parts as $key => $part) {
if (in_array($this->getPart('content-type', $part), $mime_types)
&& $this->getPart('content-disposition', $part) != 'attachment'
) {
$headers = $this->getPart('headers', $part);
$encodingType = array_key_exists('content-transfer-encoding', $headers) ? $headers['content-transfer-encoding'] : '';
if ($this->getPart('content-type', $part) == 'text/plain') {
$textBody .= $this->decodeContentTransfer($this->getPartBody($part), $encodingType);
$textBody = nl2br($this->charset->decodeCharset($textBody, $this->getPartCharset($part)));
} elseif ($this->getPart('content-type', $part) == 'text/plain; (error)') {
if (empty($part['headers']) || ! isset($part['headers']['from'])) {
$parentKey = explode('.', $key)[0];
if (isset($this->parts[$parentKey]) && isset($this->parts[$parentKey]['headers']['from'])) {
$part_from_sender = is_array($this->parts[$parentKey]['headers']['from'])
? $this->parts[$parentKey]['headers']['from'][0]
: $this->parts[$parentKey]['headers']['from'];
} else {
continue;
}
} else {
$part_from_sender = is_array($part['headers']['from'])
? $part['headers']['from'][0]
: $part['headers']['from'];
}
$mail_part_addresses = mailparse_rfc822_parse_addresses($part_from_sender);
if (! empty($mail_part_addresses[0]['address'])
&& strrpos($mail_part_addresses[0]['address'], 'pcsms.com') !== false
) {
$last_header = end($headers);
$partMessage = substr($this->data, strrpos($this->data, $last_header) + strlen($last_header), $part['ending-pos-body']);
$textBody .= $this->decodeContentTransfer($partMessage, $encodingType);
$textBody = nl2br($this->charset->decodeCharset($textBody, $this->getPartCharset($part)));
}
} elseif ($this->getPart('content-type', $part) == 'multipart/mixed'
|| $this->getPart('content-type', $part) == 'multipart/related'
) {
$emailContent = $this->extractMultipartMIMEText($part, $this->data, $encodingType);
$textBody .= $this->decodeContentTransfer($emailContent, $encodingType);
$textBody = nl2br($this->charset->decodeCharset($textBody, $this->getPartCharset($part)));
} else {
$htmlBody .= $this->decodeContentTransfer($this->getPartBody($part), $encodingType);
$htmlBody = $this->charset->decodeCharset($htmlBody, $this->getPartCharset($part));
}
}
}
$body = $htmlBody ?: $textBody;
if (is_array($this->files)) {
foreach ($this->files as $file) {
if ($file['contentId']) {
$body = str_replace('cid:'.preg_replace('/[<>]/', '', $file['contentId']), $file['path'], $body);
$path = $file['path'];
}
}
}
} else {
throw new \Exception('Invalid type specified for getMessageBody(). "type" can either be text or html.');
}
return $body;
}
/**
* Get text message body.
*
* @return string
*/
public function getTextMessageBody()
{
$textBody = null;
foreach ($this->parts as $key => $part) {
if ($this->getPart('content-disposition', $part) != 'attachment') {
$headers = $this->getPart('headers', $part);
$encodingType = array_key_exists('content-transfer-encoding', $headers) ? $headers['content-transfer-encoding'] : '';
if ($this->getPart('content-type', $part) == 'text/plain') {
$textBody .= $this->decodeContentTransfer($this->getPartBody($part), $encodingType);
$textBody = nl2br($this->charset->decodeCharset($textBody, $this->getPartCharset($part)));
} elseif ($this->getPart('content-type', $part) == 'text/plain; (error)') {
$part_from_sender = is_array($part['headers']['from']) ? $part['headers']['from'][0] : $part['headers']['from'];
$mail_part_addresses = mailparse_rfc822_parse_addresses($part_from_sender);
if (! empty($mail_part_addresses[0]['address'])
&& strrpos($mail_part_addresses[0]['address'], 'pcsms.com') !== false
) {
$last_header = end($headers);
$partMessage = substr($this->data, strrpos($this->data, $last_header) + strlen($last_header), $part['ending-pos-body']);
$textBody .= $this->decodeContentTransfer($partMessage, $encodingType);
$textBody = nl2br($this->charset->decodeCharset($textBody, $this->getPartCharset($part)));
}
} elseif ($this->getPart('content-type', $part) == 'multipart/mixed'
|| $this->getPart('content-type', $part) == 'multipart/related'
) {
$emailContent = $this->extractMultipartMIMEText($part, $this->data, $encodingType);
$textBody .= $this->decodeContentTransfer($emailContent, $encodingType);
$textBody = nl2br($this->charset->decodeCharset($textBody, $this->getPartCharset($part)));
}
}
}
return $textBody;
}
/**
* Returns the attachments contents in order of appearance.
*
* @return array
*/
public function getAttachments()
{
$attachments = [];
$dispositions = ['attachment', 'inline'];
$non_attachment_types = ['text/plain', 'text/html', 'text/plain; (error)'];
$nonameIter = 0;
foreach ($this->parts as $part) {
$disposition = $this->getPart('content-disposition', $part);
$filename = 'noname';
if (isset($part['disposition-filename'])) {
$filename = $this->decodeHeader($part['disposition-filename']);
} elseif (isset($part['content-name'])) {
// if we have no disposition but we have a content-name, it's a valid attachment.
// we simulate the presence of an attachment disposition with a disposition filename
$filename = $this->decodeHeader($part['content-name']);
$disposition = 'attachment';
} elseif (! in_array($part['content-type'], $non_attachment_types, true)
&& substr($part['content-type'], 0, 10) !== 'multipart/'
) {
// if we cannot get it by getMessageBody(), we assume it is an attachment
$disposition = 'attachment';
}
if (in_array($disposition, $dispositions) === true && isset($filename) === true) {
if ($filename == 'noname') {
$nonameIter++;
$filename = 'noname'.$nonameIter;
}
$headersAttachments = $this->getPart('headers', $part);
$contentidAttachments = $this->getPart('content-id', $part);
if (! $contentidAttachments
&& $disposition == 'inline'
&& ! strpos($this->getPart('content-type', $part), 'image/')
&& ! stripos($filename, 'noname') == false
) {
// skip
} else {
$attachments[] = new Attachment(
$filename,
$this->getPart('content-type', $part),
$this->getAttachmentStream($part),
$disposition,
$contentidAttachments,
$headersAttachments
);
}
}
}
return ! empty($attachments) ? $attachments : $this->extractMultipartMIMEAttachments();
}
/**
* Extract attachments from multipart MIME.
*
* @return array
*/
public function extractMultipartMIMEAttachments()
{
$attachmentCollection = $processedAttachmentCollection = [];
foreach ($this->parts as $part) {
$boundary = isset($part['content-boundary']) ? trim($part['content-boundary']) : '';
$boundary = substr($boundary, strpos($boundary, '----=') + strlen('----='));
preg_match_all('/------=(3D_.*)\sContent-Type:\s(.*)\s*boundary=3D"----=(3D_.*)"/', $this->data, $matches);
$delimeter = array_shift($matches);
$content_delimeter = end($delimeter);
[$relations, $content_types, $boundaries] = $matches;
$messageToProcess = substr($this->data, stripos($this->data, (string) $content_delimeter) + strlen($content_delimeter));
array_unshift($boundaries, $boundary);
// Extract the text
foreach (array_reverse($boundaries) as $index => $boundary) {
$processedEmailSegments = [];
$emailSegments = explode('------='.$boundary, $messageToProcess);
// Remove empty parts
foreach ($emailSegments as $emailSegment) {
if (! empty(trim($emailSegment))) {
$processedEmailSegments[] = trim($emailSegment);
}
}
// Remove unrelated parts
array_pop($processedEmailSegments);
for ($i = 0; $i < $index; $i++) {
if (count($processedEmailSegments) > 1) {
array_shift($processedEmailSegments);
}
}
// Parse each parts for text|html content
foreach ($processedEmailSegments as $emailSegment) {
$emailSegment = quoted_printable_decode(quoted_printable_decode($emailSegment));
if (stripos($emailSegment, 'content-type: text/plain;') === false
&& stripos($emailSegment, 'content-type: text/html;') === false
) {
$attachmentParts = explode("\n\n", $emailSegment);
if (! empty($attachmentParts) && count($attachmentParts) == 2) {
$attachmentDetails = explode("\n", $attachmentParts[0]);
$attachmentDetails = array_map(function ($item) {
return trim($item);
}, $attachmentDetails);
$attachmentData = trim($attachmentParts[1]);
$attachmentCollection[] = [
'details' => $attachmentDetails,
'data' => $attachmentData,
];
}
}
}
}
}
foreach ($attachmentCollection as $attachmentDetails) {
$stream = '';
$resourceDetails = [
'name' => '',
'fileName' => '',
'contentType' => '',
'encodingType' => 'base64',
'contentDisposition' => 'inline',
'contentId' => '',
];
foreach ($attachmentDetails['details'] as $attachmentDetail) {
if (stripos($attachmentDetail, 'Content-Type: ') === 0) {
$resourceDetails['contentType'] = substr($attachmentDetail, strlen('Content-Type: '));
} elseif (stripos($attachmentDetail, 'name="') === 0) {
$resourceDetails['name'] = substr($attachmentDetail, strlen('name="'), -1);
} elseif (stripos($attachmentDetail, 'Content-Transfer-Encoding: ') === 0) {
$resourceDetails['encodingType'] = substr($attachmentDetail, strlen('Content-Transfer-Encoding: '));
} elseif (stripos($attachmentDetail, 'Content-ID: ') === 0) {
$resourceDetails['contentId'] = substr($attachmentDetail, strlen('Content-ID: '));
} elseif (stripos($attachmentDetail, 'filename="') === 0) {
$resourceDetails['fileName'] = substr($attachmentDetail, strlen('filename="'), -1);
} elseif (stripos($attachmentDetail, 'Content-Disposition: ') === 0) {
$resourceDetails['contentDisposition'] = substr($attachmentDetail, strlen('Content-Disposition: '), -1);
}
}
$resourceDetails['name'] = empty($resourceDetails['name']) ? $resourceDetails['fileName'] : $resourceDetails['name'];
$resourceDetails['fileName'] = empty($resourceDetails['fileName']) ? $resourceDetails['name'] : $resourceDetails['fileName'];
$temp_fp = tmpfile();
fwrite($temp_fp, base64_decode($attachmentDetails['data']), strlen($attachmentDetails['data']));
fseek($temp_fp, 0, SEEK_SET);
$processedAttachmentCollection[] = new Attachment(
$resourceDetails['fileName'],
$resourceDetails['contentType'],
$temp_fp,
$resourceDetails['contentDisposition'],
$resourceDetails['contentId'], []
);
}
return $processedAttachmentCollection;
}
/**
* Read the attachment body and save temporary file resource.
*
* @return string
*/
private function getAttachmentStream(&$part)
{
$temp_fp = tmpfile();
$headers = $this->getPart('headers', $part);
$encodingType = array_key_exists('content-transfer-encoding', $headers)
? $headers['content-transfer-encoding']
: '';
if ($temp_fp) {
if ($this->stream) {
$start = $part['starting-pos-body'];
$end = $part['ending-pos-body'];
fseek($this->stream, $start, SEEK_SET);
$len = $end - $start;
$written = 0;
while ($written < $len) {
$write = $len;
$part = fread($this->stream, $write);
fwrite($temp_fp, $this->decodeContentTransfer($part, $encodingType));
$written += $write;
}
} elseif ($this->data) {
$attachment = $this->decodeContentTransfer($this->getPartBodyFromText($part), $encodingType);
fwrite($temp_fp, $attachment, strlen($attachment));
}
fseek($temp_fp, 0, SEEK_SET);
} else {
throw new \Exception(
'Could not create temporary files for attachments. Your tmp directory may be unwritable by PHP.'
);
}
return $temp_fp;
}
/**
* Decode the string from Content-Transfer-Encoding.
*
* @return string
*/
private function decodeContentTransfer($encodedString, $encodingType)
{
$encodingType = strtolower($encodingType);
if ($encodingType == 'base64') {
return base64_decode($encodedString);
} elseif ($encodingType == 'quoted-printable') {
return quoted_printable_decode($encodedString);
} else {
return $encodedString; // 8bit, 7bit, binary
}
}
/**
* Decode header.
*
* @param string|array $input
* @return string
*/
private function decodeHeader($input)
{
// sometimes we have 2 label From so we take only the first
if (is_array($input)) {
return $this->decodeSingleHeader($input[0]);
}
return $this->decodeSingleHeader($input);
}
/**
* Decodes a single header (= string).
*
* @param string
* @return string
*/
private function decodeSingleHeader($input)
{
// Remove white space between encoded-words
$input = preg_replace('/(=\?[^?]+\?(q|b)\?[^?]*\?=)(\s)+=\?/i', '\1=?', $input);
// For each encoded-word...
while (preg_match('/(=\?([^?]+)\?(q|b)\?([^?]*)\?=)/i', $input, $matches)) {
$encoded = $matches[1];
$charset = $matches[2];
$encoding = $matches[3];
$text = $matches[4];
switch (strtolower($encoding)) {
case 'b':
$text = $this->decodeContentTransfer($text, 'base64');
break;
case 'q':
$text = str_replace('_', ' ', $text);
preg_match_all('/=([a-f0-9]{2})/i', $text, $matches);
foreach ($matches[1] as $value) {
$text = str_replace('='.$value, chr(hexdec($value)), $text);
}
break;
}
$text = $this->charset->decodeCharset($text, $this->charset->getCharsetAlias($charset));
$input = str_replace($encoded, $text, $input);
}
return $input;
}
/**
* Return the charset of the MIME part.
*
* @return string|false
*/
private function getPartCharset($part)
{
if (isset($part['charset'])) {
return $charset = $this->charset->getCharsetAlias($part['charset']);
} else {
return false;
}
}
/**
* Retrieve a specified MIME part.
*
* @return string|array
*/
private function getPart($type, $parts)
{
return (isset($parts[$type])) ? $parts[$type] : false;
}
/**
* Retrieve the Body of a MIME part.
*
* @return string
*/
private function getPartBody(&$part)
{
$body = '';
if ($this->stream) {
$body = $this->getPartBodyFromFile($part);
} elseif ($this->data) {
$body = $this->getPartBodyFromText($part);
}
return $body;
}
/**
* Retrieve the Body from a MIME part from file.
*
* @return string
*/
private function getPartBodyFromFile(&$part)
{
$start = $part['starting-pos-body'];
$end = $part['ending-pos-body'];
fseek($this->stream, $start, SEEK_SET);
return fread($this->stream, $end - $start);
}
/**
* Retrieve the Body from a MIME part from text.
*
* @return string
*/
private function getPartBodyFromText(&$part)
{
$start = $part['starting-pos-body'];
$end = $part['ending-pos-body'];
return substr($this->data, $start, $end - $start);
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace Webkul\Email\InboundEmailProcessor\Contracts;
interface InboundEmailProcessor
{
/**
* Process messages from all folders.
*
* @return mixed
*/
public function processMessagesFromAllFolders();
/**
* Process the inbound email.
*
* @param mixed|null $content
*/
public function processMessage($content = null): void;
}

View File

@@ -0,0 +1,125 @@
<?php
namespace Webkul\Email\InboundEmailProcessor;
use Webkul\Email\Helpers\HtmlFilter;
use Webkul\Email\Helpers\Parser;
use Webkul\Email\InboundEmailProcessor\Contracts\InboundEmailProcessor;
use Webkul\Email\Repositories\AttachmentRepository;
use Webkul\Email\Repositories\EmailRepository;
class SendgridEmailProcessor implements InboundEmailProcessor
{
/**
* Create a new repository instance.
*
* @return void
*/
public function __construct(
protected EmailRepository $emailRepository,
protected AttachmentRepository $attachmentRepository,
protected Parser $emailParser,
protected HtmlFilter $htmlFilter
) {}
/**
* Process messages from all folders.
*/
public function processMessagesFromAllFolders()
{
/**
* SendGrid's Inbound Parse is a specialized tool for developers to handle incoming emails in
* their applications, but it doesn't replace the full functionality of IMAP for typical
* email client usage. Thats why we can't process the messages.
*/
throw new \Exception('Currently bulk processing is not supported for Sendgrid.');
}
/**
* Process the inbound email.
*/
public function processMessage($message = null): void
{
$this->emailParser->setText($message);
$email = $this->emailRepository->findOneWhere(['message_id' => $messageID = $this->emailParser->getHeader('message-id')]);
if ($email) {
return;
}
$headers = [
'from' => $this->emailParser->parseEmailAddress('from'),
'sender' => $this->emailParser->parseEmailAddress('sender'),
'reply_to' => $this->emailParser->parseEmailAddress('to'),
'cc' => $this->emailParser->parseEmailAddress('cc'),
'bcc' => $this->emailParser->parseEmailAddress('bcc'),
'subject' => $this->emailParser->getHeader('subject'),
'name' => $this->emailParser->parseSenderName(),
'source' => 'email',
'user_type' => 'person',
'message_id' => $messageID ?? time().'@'.config('mail.domain'),
'reference_ids' => htmlspecialchars_decode($this->emailParser->getHeader('references')),
'in_reply_to' => htmlspecialchars_decode($this->emailParser->getHeader('in-reply-to')),
];
foreach ($headers['reply_to'] as $to) {
if ($email = $this->emailRepository->findOneWhere(['message_id' => $to])) {
break;
}
}
if (! isset($email) && $headers['in_reply_to']) {
$email = $this->emailRepository->findOneWhere(['message_id' => $headers['in_reply_to']]);
if (! $email) {
$email = $this->emailRepository->findOneWhere([['reference_ids', 'like', '%'.$headers['in_reply_to'].'%']]);
}
}
if (! isset($email) && $headers['reference_ids']) {
$referenceIds = explode(' ', $headers['reference_ids']);
foreach ($referenceIds as $referenceId) {
if ($email = $this->emailRepository->findOneWhere([['reference_ids', 'like', '%'.$referenceId.'%']])) {
break;
}
}
}
if (! $reply = $this->emailParser->getMessageBody('text')) {
$reply = $this->emailParser->getTextMessageBody();
}
if (! isset($email)) {
$email = $this->emailRepository->create(array_merge($headers, [
'folders' => ['inbox'],
'reply' => $reply,
'unique_id' => time().'@'.config('mail.domain'),
'reference_ids' => [$headers['message_id']],
'user_type' => 'person',
]));
$this->attachmentRepository->uploadAttachments($email, [
'source' => 'email',
'attachments' => $this->emailParser->getAttachments(),
]);
} else {
$parentEmail = $this->emailRepository->update([
'folders' => array_unique(array_merge($email->folders, ['inbox'])),
'reference_ids' => array_merge($email->reference_ids ?? [], [$headers['message_id']]),
], $email->id);
$email = $this->emailRepository->create(array_merge($headers, [
'reply' => $this->htmlFilter->process($reply, ''),
'parent_id' => $parentEmail->id,
'user_type' => 'person',
]));
$this->attachmentRepository->uploadAttachments($email, [
'source' => 'email',
'attachments' => $this->emailParser->getAttachments(),
]);
}
}
}

View File

@@ -0,0 +1,228 @@
<?php
namespace Webkul\Email\InboundEmailProcessor;
use Webklex\IMAP\Facades\Client;
use Webkul\Email\Enums\SupportedFolderEnum;
use Webkul\Email\InboundEmailProcessor\Contracts\InboundEmailProcessor;
use Webkul\Email\Repositories\AttachmentRepository;
use Webkul\Email\Repositories\EmailRepository;
class WebklexImapEmailProcessor implements InboundEmailProcessor
{
/**
* The IMAP client instance.
*/
protected $client;
/**
* Create a new repository instance.
*
* @return void
*/
public function __construct(
protected EmailRepository $emailRepository,
protected AttachmentRepository $attachmentRepository
) {
$this->client = Client::make($this->getDefaultConfigs());
$this->client->connect();
if (! $this->client->isConnected()) {
throw new \Exception('Failed to connect to the mail server.');
}
}
/**
* Close the connection.
*/
public function __destruct()
{
$this->client->disconnect();
}
/**
* Process messages from all folders.
*/
public function processMessagesFromAllFolders()
{
try {
$rootFolders = $this->client->getFolders();
$this->processMessagesFromLeafFolders($rootFolders);
} catch (\Exception $e) {
throw new \Exception($e->getMessage());
}
}
/**
* Process the inbound email.
*
* @param ?\Webklex\PHPIMAP\Message $message
*/
public function processMessage($message = null): void
{
$attributes = $message->getAttributes();
$messageId = $attributes['message_id']->first();
$email = $this->emailRepository->findOneByField('message_id', $messageId);
if ($email) {
return;
}
$replyToEmails = $this->getEmailsByAttributeCode($attributes, 'to');
foreach ($replyToEmails as $to) {
if ($email = $this->emailRepository->findOneWhere(['message_id' => $to])) {
break;
}
}
if (! isset($email) && isset($attributes['in_reply_to'])) {
$inReplyTo = $attributes['in_reply_to']->first();
$email = $this->emailRepository->findOneWhere(['message_id' => $inReplyTo]);
if (! $email) {
$email = $this->emailRepository->findOneWhere([['reference_ids', 'like', '%'.$inReplyTo.'%']]);
}
}
$references = [$messageId];
if (! isset($email) && isset($attributes['references'])) {
array_push($references, ...$attributes['references']->all());
foreach ($references as $reference) {
if ($email = $this->emailRepository->findOneWhere([['reference_ids', 'like', '%'.$reference.'%']])) {
break;
}
}
}
/**
* Maps the folder name to the supported folder in our application.
*
* To Do: Review this.
*/
$folderName = match ($message->getFolder()->name) {
'INBOX' => SupportedFolderEnum::INBOX->value,
'Important' => SupportedFolderEnum::IMPORTANT->value,
'Starred' => SupportedFolderEnum::STARRED->value,
'Drafts' => SupportedFolderEnum::DRAFT->value,
'Sent Mail' => SupportedFolderEnum::SENT->value,
'Trash' => SupportedFolderEnum::TRASH->value,
default => '',
};
$parentEmail = null;
if ($email) {
$parentEmail = $this->emailRepository->update([
'folders' => array_unique(array_merge($email->folders, [$folderName])),
'reference_ids' => array_merge($email->reference_ids ?? [], [$references]),
], $email->id);
}
$email = $this->emailRepository->create([
'from' => $attributes['from']->first()->mail,
'subject' => $attributes['subject']->first(),
'name' => $attributes['from']->first()->personal,
'reply' => $message->bodies['html'] ?? $message->bodies['text'],
'is_read' => (int) $message->flags()->has('seen'),
'folders' => [$folderName],
'reply_to' => $this->getEmailsByAttributeCode($attributes, 'to'),
'cc' => $this->getEmailsByAttributeCode($attributes, 'cc'),
'bcc' => $this->getEmailsByAttributeCode($attributes, 'bcc'),
'source' => 'email',
'user_type' => 'person',
'unique_id' => $messageId,
'message_id' => $messageId,
'reference_ids' => $references,
'created_at' => $this->convertToDesiredTimezone($message->date->toDate()),
'parent_id' => $parentEmail?->id,
]);
if ($message->hasAttachments()) {
$this->attachmentRepository->uploadAttachments($email, [
'source' => 'email',
'attachments' => $message->getAttachments(),
]);
}
}
/**
* Process the messages from all folders.
*
* @param \Webklex\IMAP\Support\FolderCollection $rootFoldersCollection
*/
protected function processMessagesFromLeafFolders($rootFoldersCollection = null): void
{
$rootFoldersCollection->each(function ($folder) {
if (! $folder->children->isEmpty()) {
$this->processMessagesFromLeafFolders($folder->children);
return;
}
if (in_array($folder->name, ['All Mail'])) {
return;
}
return $folder->query()->since(now()->subDays(10))->get()->each(function ($message) {
$this->processMessage($message);
});
});
}
/**
* Get the emails by the attribute code.
*/
protected function getEmailsByAttributeCode(array $attributes, string $attributeCode): array
{
$emails = [];
if (isset($attributes[$attributeCode])) {
$emails = collect($attributes[$attributeCode]->all())->map(fn ($attribute) => $attribute->mail)->toArray();
}
return $emails;
}
/**
* Convert the date to the desired timezone.
*
* @param \Carbon\Carbon $carbonDate
* @param ?string $targetTimezone
*/
protected function convertToDesiredTimezone($carbonDate, $targetTimezone = null)
{
$targetTimezone = $targetTimezone ?: config('app.timezone');
return $carbonDate->clone()->setTimezone($targetTimezone);
}
/**
* Get the default configurations.
*/
protected function getDefaultConfigs(): array
{
$defaultConfig = config('imap.accounts.default');
$defaultConfig['host'] = core()->getConfigData('email.imap.account.host') ?: $defaultConfig['host'];
$defaultConfig['port'] = core()->getConfigData('email.imap.account.port') ?: $defaultConfig['port'];
$defaultConfig['encryption'] = core()->getConfigData('email.imap.account.encryption') ?: $defaultConfig['encryption'];
$defaultConfig['validate_cert'] = (bool) core()->getConfigData('email.imap.account.validate_cert');
$defaultConfig['username'] = core()->getConfigData('email.imap.account.username') ?: $defaultConfig['username'];
$defaultConfig['password'] = core()->getConfigData('email.imap.account.password') ?: $defaultConfig['password'];
return $defaultConfig;
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace Webkul\Email\Mails;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
use Symfony\Component\Mime\Email as MimeEmail;
class Email extends Mailable
{
use Queueable, SerializesModels;
/**
* Create a new email instance.
*
* @return void
*/
public function __construct(public $email) {}
/**
* Build the message.
*
* @return $this
*/
public function build()
{
$this->from($this->email->from)
->to($this->email->reply_to)
->replyTo($this->email->parent_id ? $this->email->parent->unique_id : $this->email->unique_id)
->cc($this->email->cc ?? [])
->bcc($this->email->bcc ?? [])
->subject($this->email->parent_id ? $this->email->parent->subject : $this->email->subject)
->html($this->email->reply);
$this->withSymfonyMessage(function (MimeEmail $message) {
$message->getHeaders()->addIdHeader('Message-ID', $this->email->message_id);
$message->getHeaders()->addTextHeader('References', $this->email->parent_id
? implode(' ', $this->email->parent->reference_ids)
: implode(' ', $this->email->reference_ids)
);
});
foreach ($this->email->attachments as $attachment) {
$this->attachFromStorage($attachment->path);
}
return $this;
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace Webkul\Email\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Storage;
use Webkul\Email\Contracts\Attachment as AttachmentContract;
class Attachment extends Model implements AttachmentContract
{
/**
* The attributes that are mass assignable.
*
* @var string
*/
protected $table = 'email_attachments';
/**
* The attributes that are appended.
*
* @var array
*/
protected $appends = ['url'];
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'name',
'path',
'size',
'content_type',
'content_id',
'email_id',
];
/**
* Get the email.
*/
public function email()
{
return $this->belongsTo(EmailProxy::modelClass());
}
/**
* Get image url for the product image.
*/
public function url()
{
return Storage::url($this->path);
}
/**
* Accessor for the 'url' attribute.
*/
public function getUrlAttribute()
{
return $this->url();
}
}

View File

@@ -0,0 +1,7 @@
<?php
namespace Webkul\Email\Models;
use Konekt\Concord\Proxies\ModelProxy;
class AttachmentProxy extends ModelProxy {}

View File

@@ -0,0 +1,127 @@
<?php
namespace Webkul\Email\Models;
use Illuminate\Database\Eloquent\Model;
use Webkul\Contact\Models\PersonProxy;
use Webkul\Email\Contracts\Email as EmailContract;
use Webkul\Lead\Models\LeadProxy;
use Webkul\Tag\Models\TagProxy;
class Email extends Model implements EmailContract
{
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'emails';
/**
* The attributes that should be cast.
*
* @var array
*/
protected $casts = [
'folders' => 'array',
'sender' => 'array',
'from' => 'array',
'reply_to' => 'array',
'cc' => 'array',
'bcc' => 'array',
'reference_ids' => 'array',
];
/**
* The attributes that are appended.
*
* @var array
*/
protected $appends = [
'time_ago',
];
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'subject',
'source',
'name',
'user_type',
'is_read',
'folders',
'from',
'sender',
'reply_to',
'cc',
'bcc',
'unique_id',
'message_id',
'reference_ids',
'reply',
'person_id',
'parent_id',
'lead_id',
'created_at',
'updated_at',
];
/**
* Get the parent email.
*/
public function parent()
{
return $this->belongsTo(EmailProxy::modelClass(), 'parent_id');
}
/**
* Get the lead.
*/
public function lead()
{
return $this->belongsTo(LeadProxy::modelClass());
}
/**
* Get the emails.
*/
public function emails()
{
return $this->hasMany(EmailProxy::modelClass(), 'parent_id');
}
/**
* Get the person that owns the thread.
*/
public function person()
{
return $this->belongsTo(PersonProxy::modelClass());
}
/**
* The tags that belong to the lead.
*/
public function tags()
{
return $this->belongsToMany(TagProxy::modelClass(), 'email_tags');
}
/**
* Get the attachments.
*/
public function attachments()
{
return $this->hasMany(AttachmentProxy::modelClass(), 'email_id');
}
/**
* Get the time ago.
*/
public function getTimeAgoAttribute(): string
{
return $this->created_at->diffForHumans();
}
}

View File

@@ -0,0 +1,7 @@
<?php
namespace Webkul\Email\Models;
use Konekt\Concord\Proxies\ModelProxy;
class EmailProxy extends ModelProxy {}

View File

@@ -0,0 +1,58 @@
<?php
namespace Webkul\Email\Providers;
use Illuminate\Support\ServiceProvider;
use Webkul\Email\Console\Commands\ProcessInboundEmails;
use Webkul\Email\InboundEmailProcessor\Contracts\InboundEmailProcessor;
use Webkul\Email\InboundEmailProcessor\SendgridEmailProcessor;
use Webkul\Email\InboundEmailProcessor\WebklexImapEmailProcessor;
class EmailServiceProvider extends ServiceProvider
{
/**
* Bootstrap services.
*
* @return void
*/
public function boot()
{
$this->loadMigrationsFrom(__DIR__.'/../Database/Migrations');
$this->app->bind(InboundEmailProcessor::class, function ($app) {
$driver = config('mail-receiver.default');
if ($driver === 'sendgrid') {
return $app->make(SendgridEmailProcessor::class);
}
if ($driver === 'webklex-imap') {
return $app->make(WebklexImapEmailProcessor::class);
}
throw new \Exception("Unsupported mail receiver driver [{$driver}].");
});
}
/**
* Register services.
*
* @return void
*/
public function register()
{
$this->registerCommands();
}
/**
* Register the console commands of this package.
*/
protected function registerCommands(): void
{
if ($this->app->runningInConsole()) {
$this->commands([
ProcessInboundEmails::class,
]);
}
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace Webkul\Email\Providers;
use Webkul\Core\Providers\BaseModuleServiceProvider;
class ModuleServiceProvider extends BaseModuleServiceProvider
{
protected $models = [
\Webkul\Email\Models\Email::class,
\Webkul\Email\Models\Attachment::class,
];
}

View File

@@ -0,0 +1,81 @@
<?php
namespace Webkul\Email\Repositories;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Webklex\PHPIMAP\Attachment as ImapAttachment;
use Webkul\Core\Eloquent\Repository;
use Webkul\Email\Contracts\Attachment;
use Webkul\Email\Contracts\Email;
class AttachmentRepository extends Repository
{
/**
* Specify model class name.
*/
public function model(): string
{
return Attachment::class;
}
/**
* Upload attachments.
*/
public function uploadAttachments(Email $email, array $data): void
{
if (
empty($data['attachments'])
|| empty($data['source'])
) {
return;
}
foreach ($data['attachments'] as $attachment) {
$attributes = $this->prepareData($email, $attachment);
if (
! empty($attachment->contentId)
&& $data['source'] === 'email'
) {
$attributes['content_id'] = $attachment->contentId;
}
$this->create($attributes);
}
}
/**
* Get the path for the attachment.
*/
private function prepareData(Email $email, UploadedFile|ImapAttachment $attachment): array
{
if ($attachment instanceof UploadedFile) {
$name = $attachment->getClientOriginalName();
$content = file_get_contents($attachment->getRealPath());
$mimeType = $attachment->getMimeType();
} else {
$name = $attachment->name;
$content = $attachment->content;
$mimeType = $attachment->mime;
}
$path = 'emails/'.$email->id.'/'.$name;
Storage::put($path, $content);
$attributes = [
'path' => $path,
'name' => $name,
'content_type' => $mimeType,
'size' => Storage::size($path),
'email_id' => $email->id,
];
return $attributes;
}
}

View File

@@ -0,0 +1,95 @@
<?php
namespace Webkul\Email\Repositories;
use Illuminate\Container\Container;
use Webkul\Core\Eloquent\Repository;
use Webkul\Email\Contracts\Email;
class EmailRepository extends Repository
{
public function __construct(
protected AttachmentRepository $attachmentRepository,
Container $container
) {
parent::__construct($container);
}
/**
* Specify model class name.
*
* @return mixed
*/
public function model()
{
return Email::class;
}
/**
* Create.
*
* @return \Webkul\Email\Contracts\Email
*/
public function create(array $data)
{
$uniqueId = time().'@'.config('mail.domain');
$referenceIds = [];
if (isset($data['parent_id'])) {
$parent = parent::findOrFail($data['parent_id']);
$referenceIds = $parent->reference_ids ?? [];
}
$data = $this->sanitizeEmails(array_merge([
'source' => 'web',
'from' => config('mail.from.address'),
'user_type' => 'admin',
'folders' => isset($data['is_draft']) ? ['draft'] : ['outbox'],
'unique_id' => $uniqueId,
'message_id' => $uniqueId,
'reference_ids' => array_merge($referenceIds, [$uniqueId]),
], $data));
$email = parent::create($data);
$this->attachmentRepository->uploadAttachments($email, $data);
return $email;
}
/**
* Update.
*
* @param int $id
* @param string $attribute
* @return \Webkul\Email\Contracts\Email
*/
public function update(array $data, $id, $attribute = 'id')
{
return parent::update($this->sanitizeEmails($data), $id);
}
/**
* Sanitize emails.
*
* @return array
*/
public function sanitizeEmails(array $data)
{
if (isset($data['reply_to'])) {
$data['reply_to'] = array_values(array_filter($data['reply_to']));
}
if (isset($data['cc'])) {
$data['cc'] = array_values(array_filter($data['cc']));
}
if (isset($data['bcc'])) {
$data['bcc'] = array_values(array_filter($data['bcc']));
}
return $data;
}
}