Compare commits

...
Sign in to create a new pull request.

10 commits

Author SHA1 Message Date
Corentin Huet
cec2ab5343 fix: 🐛 some changes after review 2026-10-08 10:06:20 +02:00
Corentin Huet
174171bd34 fix: 🚨 resolve phpcs errors 2026-06-09 15:55:10 +02:00
Corentin Huet
45ab798b59 docs: 💡 add missing phpdoc comment 2026-06-09 15:09:52 +02:00
Corentin Huet
8170bb395a Revert "refactor: 🔥 remove newsletter_email field from brands table"
This reverts commit 27d0c1fc21.
2026-06-04 15:40:09 +02:00
Corentin Huet
b04ca114ae feat: ✨ handle Mailjet spam complaints via webhook 2026-06-04 15:34:29 +02:00
Corentin Huet
27d0c1fc21 refactor: 🔥 remove newsletter_email field from brands table 2026-05-28 11:17:45 +02:00
Corentin Huet
d0828ae213 feat: ✨ add mailjet contact list creation using api 2026-05-28 11:04:27 +02:00
Corentin Huet
b30af29d0f feat: ✨ sync unsubscribes from contact lists instead of campaigns 2026-05-26 15:52:56 +02:00
Corentin Huet
6fabb40ab6 feat: ✨ sync daily unsubscribed contacts from mailjet 2026-05-26 14:58:35 +02:00
Corentin Huet
98ef3c826e feat: ✨ add newsletter_email field to brands table 2026-05-26 14:57:40 +02:00
25 changed files with 1010 additions and 96 deletions

View file

@ -49,3 +49,7 @@ SAML2_SP_CERT_x509=
SAML2_SP_CERT_PRIVATEKEY=
SAML2_CONTACT_TECHNICAL_EMAIL=
SAML2_CONTACT_SUPPORT_EMAIL=
# MJ - API
MJ_APIKEY_PUBLIC=
MJ_APIKEY_PRIVATE=

View file

@ -28,6 +28,7 @@ class Kernel extends ConsoleKernel
// @codeCoverageIgnoreStart
$schedule->job(StartStatJobs::class)->dailyAt('03:30');
$schedule->job(SendTransferredToDigitalCountMail::class)->weekdays()->at('07:00');
$schedule->job(SyncEmailOptOuts::class)->dailyAt('00:00');
$schedule->command(DeleteUnconfirmedCustomers::class)->dailyAt('05:30');
// @codeCoverageIgnoreEnd
}

View file

@ -0,0 +1,32 @@
<?php
namespace App\Exceptions;
use Mailjet\Response;
use RuntimeException;
class MailjetException extends RuntimeException
{
/**
* Error message returned by the Mailjet API
*
* @var string
*/
public string $returnedMessage;
/**
* Create a new Mailjet exception
*
* @param \Mailjet\Response $response
* @param string $message
*/
public function __construct(Response $response, string $message)
{
$this->returnedMessage = $response->getBody()['ErrorMessage'] ?? $response->getReasonPhrase();
parent::__construct(
$message,
$response->getStatus()
);
}
}

View file

@ -0,0 +1,46 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Lib\Helpers\MailjetHelper;
use App\Models\Brand;
use Illuminate\Http\Request;
use App\Exceptions\MailjetException;
class WebhookController extends Controller
{
/**
* Handles incoming Mailjet webhook events
*
* @param Request $request
* @return \Illuminate\Http\JsonResponse
*/
public function handleMailjetWebhook(Request $request): \Illuminate\Http\JsonResponse
{
$data = $request->all();
if ($data["event"] === "spam") {
try {
$campaign = MailjetHelper::getCampaign($data['mj_campaign_id']);
if (!$campaign) {
return response()->json(['status' => 'error', 'message' => 'Campaign not found']);
}
$brand = Brand::getBrandByNewsletterEmail($campaign[0]['FromEmail']);
if (!$brand) {
return response()->json(['status' => 'error', 'message' => 'Brand not found']);
}
MailjetHelper::unsubscribeContact($brand, $data['email']);
return response()->json(['status' => 'ok']);
} catch (MailjetException $e) {
report($e);
return response()->json(['status' => 'error', 'message' => $e->returnedMessage]);
}
}//end if
}
}

View file

@ -12,6 +12,7 @@ use App\Models\CustomerInfo;
use App\Models\CustomerLogin;
use App\Models\Question;
use App\Models\SingleOptin;
use App\Models\BrandContactList;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\JoinClause;
use Illuminate\Http\Request;
@ -23,6 +24,8 @@ use Illuminate\Support\Facades\Session;
use Illuminate\Support\Str;
use League\Csv\Writer;
use Symfony\Component\HttpFoundation\StreamedResponse;
use App\Lib\Helpers\MailjetHelper;
use App\Exceptions\MailjetException;
class CustomerController extends Controller
{
@ -168,34 +171,19 @@ class CustomerController extends Controller
* Exports customers following the requested export type.
*
* @param \App\Http\Requests\Bo\CustomerExport $request
* @return \Symfony\Component\HttpFoundation\StreamedResponse
* @return \Symfony\Component\HttpFoundation\StreamedResponse|\Illuminate\Http\RedirectResponse
* @throws \Exception If the request export type in not handled.
*/
public function exportSubmit(CustomerExport $request): \Symfony\Component\HttpFoundation\StreamedResponse
{
public function exportSubmit(
CustomerExport $request
): \Symfony\Component\HttpFoundation\StreamedResponse|\Illuminate\Http\RedirectResponse {
$pagination = 2000;
$header = '"lastname";"firstname";"email";"hashcode"';
$w = Writer::createFromString($header . PHP_EOL);
$w->setDelimiter(';');
$w->setEnclosure('"');
$w->setOutputBOM(Writer::BOM_UTF8);
$w = $this->buildCsvWriter($request->action);
$exportType = CustomerExportType::make(\intval($request->export_type));
// * We send the CSV using Symfony StreamedResponse.
$response = new StreamedResponse();
$response->headers->set('Content-Encoding', 'none');
$response->headers->set('Content-Type', 'text/csv; charset=UTF-8');
$response->headers->set('Description', 'File Transfer');
$response->headers->set(
'Content-Disposition',
sprintf(
'attachment; filename="Export pour newsletter %s %s.csv"',
Carbon::now()->isoFormat('DD-MM-YYYY_HH-mm'),
Str::slug($exportType->label())
)
);
$response = $this->buildStreamedResponse($exportType);
switch ($exportType) {
case CustomerExportType::both:
@ -247,24 +235,11 @@ class CustomerController extends Controller
throw new \Exception(sprintf('Unhandled CustomerExportType %s', $exportType->label()));
} //end switch
/*
We create a callable to output the CSV in chunk
with Symfony StreamResponse you can flush the body content if necessary
see Symfony documentation for more information
*/
if ($request->action === "export") {
return $this->buildCsvDownloadResponse($response, $w);
}
// * The flush value should depend on your CSV size.
$flush_threshold = 1000;
$response->setCallback(function () use ($w, $flush_threshold) {
foreach ($w->chunk(1024) as $offset => $chunk) {
echo $chunk;
if ($offset % $flush_threshold === 0) {
flush();
}
}
});
return $response;
return $this->importToMailjet($request, $w);
}
/**
@ -313,6 +288,123 @@ class CustomerController extends Controller
->join('brands', fn($join) => $join->on('brands.id', '=', 'customer_infos.brand_id'));
}
/**
* Build the streamed HTTP response.
*
* @param CustomerExportType $exportType
* @return StreamedResponse
*/
private function buildStreamedResponse(CustomerExportType $exportType): StreamedResponse
{
// * We send the CSV using Symfony StreamedResponse.
$response = new StreamedResponse();
$response->headers->set('Content-Encoding', 'none');
$response->headers->set('Content-Type', 'text/csv; charset=UTF-8');
$response->headers->set('Description', 'File Transfer');
$response->headers->set(
'Content-Disposition',
sprintf(
'attachment; filename="Export pour newsletter %s %s.csv"',
Carbon::now()->isoFormat('DD-MM-YYYY_HH-mm'),
Str::slug($exportType->label())
)
);
return $response;
}
/**
* Build the CSV writer.
*
* @param string $action
* @return Writer
*/
private function buildCsvWriter(string $action): Writer
{
if ($action === 'export') {
$header = '"lastname";"firstname";"email";"hashcode"';
$w = Writer::createFromString($header . PHP_EOL);
$w->setDelimiter(';');
} else {
$header = 'lastname,firstname,email,hashcode';
$w = Writer::createFromString($header . PHP_EOL);
$w->setDelimiter(',');
}
$w->setEnclosure('"');
$w->setOutputBOM(Writer::BOM_UTF8);
return $w;
}
/**
* Attach a download callback to the response.
*
* @param StreamedResponse $response
* @param Writer $writer
* @return StreamedResponse
*/
private function buildCsvDownloadResponse(StreamedResponse $response, Writer $writer): StreamedResponse
{
/*
We create a callable to output the CSV in chunk
with Symfony StreamResponse you can flush the body content if necessary
see Symfony documentation for more information
*/
// * The flush value should depend on your CSV size.
$flush_threshold = 1000;
$response->setCallback(function () use ($writer, $flush_threshold) {
foreach ($writer->chunk(1024) as $offset => $chunk) {
echo $chunk;
if ($offset % $flush_threshold === 0) {
flush();
}
}
});
return $response;
}
/**
* Import contacts to Mailjet
*
* @param CustomerExport $request
* @param Writer $writer
* @return \Illuminate\Http\RedirectResponse
*/
private function importToMailjet(CustomerExport $request, Writer $writer): \Illuminate\Http\RedirectResponse
{
$brandModel = Brand::find($request->brand);
try {
$contactList = MailjetHelper::createContactList(
sprintf("%s - %s", now()->format('d/m/Y'), $brandModel->name)
);
$mailjetListId = $contactList[0]['ID'];
$csvData = MailjetHelper::uploadCsvFile($mailjetListId, $writer->toString());
MailjetHelper::importContacts($mailjetListId, $csvData['ID']);
BrandContactList::createBrandContactList(
$brandModel,
$mailjetListId,
);
return back()->with('success', 'Liste créée et contacts importés avec succès.');
} catch (MailjetException $e) {
report($e);
return back()->withErrors(sprintf(
'Impossible de créer la liste de contacts Mailjet : %s.',
$e->returnedMessage
));
}//end try
}
/**
* Apply all filters (brand, question, answer).
*

View file

@ -37,10 +37,11 @@ class StoreBrandRequest extends FormRequest
public function rules(): array
{
$rules = [
'name' => 'required|unique:brands,name|string|min:5|max:255',
'url' => 'required|url|min:5|max:255',
'url_claim' => 'required|url|min:5|max:255',
'domain' => 'required|unique:brands,domain|string|min:5|max:255',
'name' => 'required|unique:brands,name|string|min:5|max:255',
'url' => 'required|url|min:5|max:255',
'url_claim' => 'required|url|min:5|max:255',
'domain' => 'required|unique:brands,domain|string|min:5|max:255',
'newsletter_email' => 'nullable|email:rfc,strict,dns,spoof,filter',
];
return \array_merge(
$rules,
@ -56,11 +57,12 @@ class StoreBrandRequest extends FormRequest
public function attributes(): array
{
return [
'name' => trans('validation.attributes.name'),
'logo' => trans('validation.custom.logo'),
'url' => trans('validation.custom.url'),
'url_claim' => trans('validation.custom.url_claim'),
'domain' => trans('validation.custom.domain'),
'name' => trans('validation.attributes.name'),
'logo' => trans('validation.custom.logo'),
'url' => trans('validation.custom.url'),
'url_claim' => trans('validation.custom.url_claim'),
'domain' => trans('validation.custom.domain'),
'newsletter_email' => trans('validation.custom.newsletter_email'),
];
}
}

View file

@ -35,7 +35,8 @@ class CustomerExport extends FormRequest
$fail(trans('Le type d\'export invalide'));
}
}
]
],
'action' => 'required|in:export,create_mailjet'
];
}
}

View file

@ -0,0 +1,53 @@
<?php
namespace App\Jobs;
use App\Lib\Helpers\MailjetHelper;
use App\Models\BrandContactList;
use Carbon\Carbon;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use App\Exceptions\MailjetException;
class SyncEmailOptOuts implements ShouldQueue
{
use Queueable;
/**
* Execute the job.
*
* @return void
*/
public function handle(): void
{
try {
$contactLists = MailjetHelper::getContactLists();
if (empty($contactLists)) {
return;
}
foreach ($contactLists as $contactList) {
$listId = $contactList['ID'];
$brandModel = BrandContactList::getBrandContactListByMailjetListId($listId);
if ($brandModel) {
$unsubscribedContacts = MailjetHelper::getUnsubscribedContacts($listId, Carbon::now()->subDay());
if (empty($unsubscribedContacts)) {
continue;
}
foreach ($unsubscribedContacts as $unsubscribedContact) {
$contact = MailjetHelper::getContact($unsubscribedContact['ContactID']);
MailjetHelper::unsubscribeContact($brandModel->brand, $contact[0]["Email"]);
}
}
}
} catch (MailjetException $e) {
report($e);
return;
}//end try
}
}

View file

@ -0,0 +1,286 @@
<?php
namespace App\Lib\Helpers;
use App\Models\Brand;
use App\Models\CustomerInfo;
use App\Models\SingleOptin;
use App\Exceptions\MailjetException;
use Illuminate\Support\Facades\Cache;
use Mailjet\Resources;
class MailjetHelper
{
/**
* Get a campaign by ID
*
* @param integer $campaignId
* @return array
* @throws \App\Exceptions\MailjetException If the Mailjet API call fails.
*/
public static function getCampaign(int $campaignId): array
{
return Cache::remember("mailjet_campaign_{$campaignId}", 86400, function () use ($campaignId) {
$response = app('mailjetClient')->get(Resources::$Campaign, [
'id' => $campaignId
]);
if ($response->success()) {
return $response->getData();
} else {
throw new MailjetException(
$response,
sprintf(
'Mailjet API Error: Unable to retrieve campaign
(Campaign ID: %d | Error Code: %d | Message: %s)',
$campaignId,
$response->getStatus(),
$response->getBody()['ErrorMessage'] ?? $response->getReasonPhrase()
),
);
}
});
}
/**
* Retrieves contact lists
*
* @return array
* @throws \App\Exceptions\MailjetException If the Mailjet API call fails.
*/
public static function getContactLists(): array
{
$filters = [
'Limit' => 1000,
'Sort' => 'CreatedAt DESC',
];
$response = app('mailjetClient')->get(Resources::$Contactslist, [
'filters' => $filters
]);
if ($response->success()) {
return $response->getData();
} else {
throw new MailjetException(
$response,
sprintf(
'Mailjet API Error: Unable to retrieve contact lists (Code: %d | Message: %s)',
$response->getStatus(),
$response->getBody()['ErrorMessage'] ?? $response->getReasonPhrase()
),
);
}
}
/**
* Get unsubscribed contacts for a given contact list
*
* @param integer $contactListId
* @param \Carbon\Carbon|null $lastActivity
* @return array
* @throws \App\Exceptions\MailjetException If the Mailjet API call fails.
*/
public static function getUnsubscribedContacts(int $contactListId, \Carbon\Carbon|null $lastActivity = null): array
{
$filters = [
'ContactsList' => $contactListId,
'Unsub' => true,
'Limit' => 1000,
];
if (!is_null($lastActivity)) {
$filters = array_merge(
$filters,
[
'LastActivityAt' => $lastActivity->toRfc3339String()
]
);
}
$response = app('mailjetClient')->get(Resources::$Listrecipient, [
'filters' => $filters
]);
if ($response->success()) {
return $response->getData();
} else {
throw new MailjetException(
$response,
sprintf(
'Mailjet API Error: Unable to retrieve unsubscribed contacts for list %d (Code: %d | Message: %s)',
$contactListId,
$response->getStatus(),
$response->getBody()['ErrorMessage'] ?? $response->getReasonPhrase()
),
);
}
}
/**
* Get contact for a given contact id
*
* @param integer $contactId
* @return array
* @throws \App\Exceptions\MailjetException If the Mailjet API call fails.
*/
public static function getContact(int $contactId): array
{
$response = app('mailjetClient')->get(Resources::$Contact, [
'id' => $contactId
]);
if ($response->success()) {
return $response->getData();
} else {
throw new MailjetException(
$response,
sprintf(
'Mailjet API Error: Unable to retrieve contact (Contact ID: %d | Error Code: %d | Message: %s)',
$contactId,
$response->getStatus(),
$response->getBody()['ErrorMessage'] ?? $response->getReasonPhrase()
),
);
}
}
/**
* Creates a new Mailjet contact list
*
* @param string $listName
* @return array
* @throws \App\Exceptions\MailjetException If the Mailjet API call fails.
*/
public static function createContactList(string $listName): array
{
$response = app('mailjetClient')->post(Resources::$Contactslist, [
'body' => [
'Name' => $listName,
]
]);
if ($response->success()) {
return $response->getData();
} else {
throw new MailjetException(
$response,
sprintf(
'Mailjet API Error: Unable to create contact list (List Name: %s | Error Code: %d | Message: %s)',
$listName,
$response->getStatus(),
$response->getBody()['ErrorMessage'] ?? $response->getReasonPhrase()
)
);
}
}
/**
* Uploads a CSV file
*
* @param integer $mailjetListId
* @param string $csvFile
* @return array
* @throws \App\Exceptions\MailjetException If the Mailjet API call fails.
*/
public static function uploadCsvFile(int $mailjetListId, string $csvFile): array
{
$response = app('mailjetClient')->post(Resources::$ContactslistCsvdata, [
'id' => $mailjetListId,
'body' => $csvFile,
]);
if ($response->success()) {
return $response->getData();
} else {
throw new MailjetException(
$response,
sprintf(
'Mailjet API Error: Unable to upload csv file (List ID: %d | Error Code: %d | Message: %s)',
$mailjetListId,
$response->getStatus(),
$response->getBody()['ErrorMessage'] ?? $response->getReasonPhrase()
)
);
}
}
/**
* Imports contacts into a Mailjet contact list
*
* @param integer $listId
* @param integer $dataId
* @return array
* @throws \App\Exceptions\MailjetException If the Mailjet API call fails.
*/
public static function importContacts(int $listId, int $dataId): array
{
$response = app('mailjetClient')->post(Resources::$Csvimport, [
'body' => [
'ContactsListID' => $listId,
'DataID' => $dataId,
'Method' => "addnoforce"
]
]);
if ($response->success()) {
return $response->getData();
} else {
throw new MailjetException(
$response,
sprintf(
'Mailjet API Error: Unable to import contacts
(List ID: %d | Data ID: %d | Error Code: %d | Message: %s)',
$listId,
$dataId,
$response->getStatus(),
$response->getBody()['ErrorMessage'] ?? $response->getReasonPhrase()
),
);
}
}
/**
* Unsubscribe a contact from a brand
*
* @param \App\Models\Brand $brandModel
* @param string $email
* @return void
* @throws \Exception When optin status cannot be changed.
*/
public static function unsubscribeContact(Brand $brandModel, string $email): void
{
$crmCustomer = CustomerInfo::getFromEmail($brandModel, $email)->first();
if ($crmCustomer) {
$crmCustomer->opt_in = false;
if (!$crmCustomer->saveOrFail()) {
throw new \Exception(
sprintf(
'Optin status not changed for customer (%s) and brand (%d).',
$crmCustomer->hashcode,
$brandModel->name
)
);
}
} else {
$singleOptin = SingleOptin::query()->where('email', $email)
->whereHas('brand', function ($q) use ($brandModel) {
$q->whereId($brandModel->getKey());
})->first();
if ($singleOptin) {
if (!$singleOptin->delete()) {
throw new \Exception(
sprintf(
'SingleOptin (%s) not deleted for brand (%d).',
$singleOptin->hashcode,
$brandModel->name
)
);
}
}
} //end if
}
}

View file

@ -3,6 +3,7 @@
namespace App\Models;
use App\Enums\Users\RoleEnum;
use App\Models\BrandContactList;
use App\Traits\Models\ActivityLog;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@ -10,14 +11,15 @@ use Illuminate\Support\Facades\Cache;
use Uwil\LaravelCommon\Helpers\FileHelper;
/**
* @property integer $id Brand id.
* @property string $name Brand name.
* @property string $logo Brand logo.
* @property string $url Brand url link.
* @property string $url_claim Brand associative url claim.
* @property string $domain Brand domain.
* @property-read \Illuminate\Support\Carbon $created_at Brand created date.
* @property-read \Illuminate\Support\Carbon $updated_at Brand updated date.
* @property integer $id Brand id.
* @property string $name Brand name.
* @property string $logo Brand logo.
* @property string $url Brand url link.
* @property string $url_claim Brand associative url claim.
* @property string $domain Brand domain.
* @property string $newsletter_email Brand associative newsletter email.
* @property-read \Illuminate\Support\Carbon $created_at Brand created date.
* @property-read \Illuminate\Support\Carbon $updated_at Brand updated date.
*
* @property-read \Illuminate\Database\Eloquent\Collection<App\Models\Service>
* $services Service HasMany relation.
@ -46,6 +48,7 @@ class Brand extends Model
'url',
'url_claim',
'domain',
'newsletter_email',
];
/**
@ -141,6 +144,19 @@ class Brand extends Model
});
}
/**
* Get brand by newsletter_email.
*
* @param string $newsletter_email
* @return Brand|null
*/
public static function getBrandByNewsletterEmail(string $newsletter_email): ?Brand
{
return Cache::remember("brand_{$newsletter_email}", 86400, function () use ($newsletter_email) {
return self::query()->where('newsletter_email', $newsletter_email)->first();
});
}
// * RELATIONS
/**
@ -232,4 +248,15 @@ class Brand extends Model
{
return $this->hasMany(ClaimMailTemplate::class);
}
/**
* BrandContactList has many relation.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function contactLists(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(BrandContactList::class);
}
}

View file

@ -0,0 +1,65 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Cache;
class BrandContactList extends Model
{
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = [
'brand_id',
'mailjet_list_id',
];
// * METHODS
/**
* Create a brand contact list.
*
* @param Brand $brand
* @param integer $mailjetListId
* @return BrandContactList
*/
public static function createBrandContactList(Brand $brand, int $mailjetListId): BrandContactList
{
$brandContactList = self::create([
'brand_id' => $brand->getKey(),
'mailjet_list_id' => $mailjetListId,
]);
Cache::put("brand_contact_list_{$mailjetListId}", $brandContactList, 86400);
return $brandContactList;
}
/**
* Get brand contact list by mailjet_list_id.
*
* @param integer $mailjetListId
* @return BrandContactList|null
*/
public static function getBrandContactListByMailjetListId(int $mailjetListId): ?BrandContactList
{
return Cache::remember("brand_contact_list_{$mailjetListId}", 86400, function () use ($mailjetListId) {
return self::query()->where('mailjet_list_id', $mailjetListId)->with('brand')->first();
});
}
// * RELATIONS
/**
* Brand belongs to relation.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function brand(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(Brand::class);
}
}

View file

@ -0,0 +1,34 @@
<?php
namespace App\Providers;
use Illuminate\Support\Facades\App;
use Illuminate\Support\ServiceProvider;
class MailjetServiceProvider extends ServiceProvider
{
/**
* Bootstrap services.
*
* @return void
*/
public function boot(): void
{
// Mailjet client to call the API.
App::singleton('mailjetClient', function () {
$mailjetClient = new \Mailjet\Client(
config('services.mailjet.public'),
config('services.mailjet.secret'),
true,
[
'version' => 'v3',
]
);
$mailjetClient->setConnectionTimeout(200);
$mailjetClient->setTimeout(200);
return $mailjetClient;
});
}
}

View file

@ -37,6 +37,7 @@
"laravel/tinker": "^2.8",
"laravel/ui": "^4.2",
"league/csv": "^9.7",
"mailjet/mailjet-apiv3-php": "^1.6",
"mews/purifier": "^3.4",
"propaganistas/laravel-phone": "^5.0",
"sentry/sentry-laravel": "^4.10",

167
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "1417f675b37867e72bdd34d5b0fcf629",
"content-hash": "70aad663cba0f4125a01bc8e53999abc",
"packages": [
{
"name": "24slides/laravel-saml2",
@ -3562,6 +3562,67 @@
],
"time": "2024-12-08T08:18:47+00:00"
},
{
"name": "mailjet/mailjet-apiv3-php",
"version": "v1.6.6",
"source": {
"type": "git",
"url": "https://github.com/mailjet/mailjet-apiv3-php.git",
"reference": "9d5cea25f347719d7df7909fb4a43a72bd4e5011"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/mailjet/mailjet-apiv3-php/zipball/9d5cea25f347719d7df7909fb4a43a72bd4e5011",
"reference": "9d5cea25f347719d7df7909fb4a43a72bd4e5011",
"shasum": ""
},
"require": {
"ext-json": "*",
"guzzlehttp/guzzle": "^7.4.4",
"php": "^8.1",
"psr/http-client": "^1.0",
"symfony/validator": "^6.3|^7.0"
},
"require-dev": {
"mockery/mockery": "^1.4",
"php-coveralls/php-coveralls": "^2.0",
"phpcompatibility/php-compatibility": "*",
"phpstan/phpstan": "^2.0",
"phpunit/phpunit": "^11.4.0",
"squizlabs/php_codesniffer": "*"
},
"type": "library",
"autoload": {
"psr-0": {
"Mailjet": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Mailjet",
"email": "dev@mailjet.com",
"homepage": "https://dev.mailjet.com"
}
],
"description": "PHP wrapper for the Mailjet API",
"homepage": "https://github.com/mailjet/mailjet-apiv3-php/",
"keywords": [
"Mailjet",
"api",
"email",
"php",
"v3"
],
"support": {
"issues": "https://github.com/mailjet/mailjet-apiv3-php/issues",
"source": "https://github.com/mailjet/mailjet-apiv3-php/tree/v1.6.6"
},
"time": "2025-12-24T15:55:48+00:00"
},
{
"name": "masterminds/html5",
"version": "2.10.0",
@ -7953,6 +8014,110 @@
],
"time": "2024-09-25T14:21:43+00:00"
},
{
"name": "symfony/validator",
"version": "v7.4.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/validator.git",
"reference": "8f73cbddae916756f319b3e195088da216f0f12f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/validator/zipball/8f73cbddae916756f319b3e195088da216f0f12f",
"reference": "8f73cbddae916756f319b3e195088da216f0f12f",
"shasum": ""
},
"require": {
"php": ">=8.2",
"symfony/deprecation-contracts": "^2.5|^3",
"symfony/polyfill-ctype": "~1.8",
"symfony/polyfill-mbstring": "~1.0",
"symfony/polyfill-php83": "^1.27",
"symfony/translation-contracts": "^2.5|^3"
},
"conflict": {
"doctrine/lexer": "<1.1",
"symfony/dependency-injection": "<6.4",
"symfony/doctrine-bridge": "<7.0",
"symfony/expression-language": "<6.4",
"symfony/http-kernel": "<6.4",
"symfony/intl": "<6.4",
"symfony/property-info": "<6.4",
"symfony/translation": "<6.4.3|>=7.0,<7.0.3",
"symfony/var-exporter": "<6.4.25|>=7.0,<7.3.3",
"symfony/yaml": "<6.4"
},
"require-dev": {
"egulias/email-validator": "^2.1.10|^3|^4",
"symfony/cache": "^6.4|^7.0|^8.0",
"symfony/config": "^6.4|^7.0|^8.0",
"symfony/console": "^6.4|^7.0|^8.0",
"symfony/dependency-injection": "^6.4|^7.0|^8.0",
"symfony/expression-language": "^6.4|^7.0|^8.0",
"symfony/finder": "^6.4|^7.0|^8.0",
"symfony/http-client": "^6.4|^7.0|^8.0",
"symfony/http-foundation": "^6.4|^7.0|^8.0",
"symfony/http-kernel": "^6.4|^7.0|^8.0",
"symfony/intl": "^6.4|^7.0|^8.0",
"symfony/mime": "^6.4|^7.0|^8.0",
"symfony/process": "^6.4|^7.0|^8.0",
"symfony/property-access": "^6.4|^7.0|^8.0",
"symfony/property-info": "^6.4|^7.0|^8.0",
"symfony/string": "^6.4|^7.0|^8.0",
"symfony/translation": "^6.4.3|^7.0.3|^8.0",
"symfony/type-info": "^7.1.8",
"symfony/yaml": "^6.4|^7.0|^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\Validator\\": ""
},
"exclude-from-classmap": [
"/Tests/",
"/Resources/bin/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides tools to validate values",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/validator/tree/v7.4.8"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-03-30T12:55:43+00:00"
},
{
"name": "symfony/var-dumper",
"version": "v7.2.0",

View file

@ -196,6 +196,7 @@ return [
App\Providers\RouteServiceProvider::class,
App\Providers\TranslationServiceProvider::class,
App\Providers\ViewServiceProvider::class,
App\Providers\MailjetServiceProvider::class,
],
/*

View file

@ -31,4 +31,8 @@ return [
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
],
'mailjet' => [
'public' => env('MJ_APIKEY_PUBLIC'),
'secret' => env('MJ_APIKEY_PRIVATE'),
],
];

View file

@ -22,10 +22,11 @@ class BrandFactory extends Factory
public function definition(): array
{
return [
'name' => $this->faker->unique()->company,
'url' => $this->faker->url,
'url_claim' => $this->faker->url,
'domain' => $this->faker->unique()->domainName,
'name' => $this->faker->unique()->company,
'url' => $this->faker->url,
'url_claim' => $this->faker->url,
'domain' => $this->faker->unique()->domainName,
'newsletter_email' => $this->faker->email
];
}
}

View file

@ -0,0 +1,28 @@
<?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::table('brands', function (Blueprint $table) {
$table->string('newsletter_email')->nullable()->comment('Brand associative newsletter email.')->after('domain');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('brands', function (Blueprint $table) {
$table->dropColumn('newsletter_email');
});
}
};

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('brand_contact_lists', function (Blueprint $table) {
$table->id();
$table->foreignId('brand_id')->comment('Brand associated.')->constrained('brands')
->onUpdate('cascade')->onDelete('cascade');
$table->unsignedBigInteger('mailjet_list_id')->unique();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('brand_contact_lists');
}
};

View file

@ -32,6 +32,8 @@ data:
SAML2_CONTACT_TECHNICAL_EMAIL: "${APP_SAML2_CONTACT_TECHNICAL_EMAIL}"
SAML2_CONTACT_SUPPORT_EMAIL: "${APP_SAML2_CONTACT_SUPPORT_EMAIL}"
UWIL_ADMIN_SECURE_CIDR_LIST: "${APP_GLOBAL_UWIL_ADMIN_SECURE_CIDR_LIST}"
MJ_APIKEY_PUBLIC: "${APP_MJ_APIKEY_PUBLIC}"
MJ_APIKEY_PRIVATE: "${APP_MJ_APIKEY_PRIVATE}"
---
apiVersion: v1
@ -82,7 +84,12 @@ spec:
lifecycle:
preStop:
exec:
command: ["/bin/sh","-c","kill -s TERM 1; while test $(ps ax | grep -v grep | grep bash | wc -l) -gt 0; do sleep 2s; done"]
command:
[
"/bin/sh",
"-c",
"kill -s TERM 1; while test $(ps ax | grep -v grep | grep bash | wc -l) -gt 0; do sleep 2s; done",
]
resources:
limits:
cpu: "${APP_CI_CPU_LIMIT}"

View file

@ -222,20 +222,21 @@ return [
'year' => 'year',
],
'custom' => [
'event' => 'event',
'model' => 'target element',
'order' => 'order',
'publication' => 'publication',
'activated' => 'activated',
'target' => 'target',
'response_type' => 'response type',
'reference' => 'reference',
'type' => 'type',
'status' => 'status',
'label' => 'label',
'domain' => 'domain',
'logo' => 'logo',
'url' => 'url',
'url_claim' => 'claim url',
'event' => 'event',
'model' => 'target element',
'order' => 'order',
'publication' => 'publication',
'activated' => 'activated',
'target' => 'target',
'response_type' => 'response type',
'reference' => 'reference',
'type' => 'type',
'status' => 'status',
'label' => 'label',
'domain' => 'domain',
'logo' => 'logo',
'url' => 'url',
'url_claim' => 'claim url',
'newsletter_email' => 'email address for the newsletter',
],
];

View file

@ -222,20 +222,21 @@ return [
'year' => 'année',
],
'custom' => [
'event' => 'évènement',
'model' => 'élément ciblé',
'order' => 'order',
'publication' => 'publication',
'activated' => 'activé',
'target' => 'cible',
'response_type' => 'type de réponse',
'reference' => 'référence',
'type' => 'type',
'status' => 'status',
'label' => 'libellé',
'domain' => 'domaine',
'logo' => 'logo',
'url' => 'lien',
'url_claim' => 'lien réclamation',
'event' => 'évènement',
'model' => 'élément ciblé',
'order' => 'order',
'publication' => 'publication',
'activated' => 'activé',
'target' => 'cible',
'response_type' => 'type de réponse',
'reference' => 'référence',
'type' => 'type',
'status' => 'status',
'label' => 'libellé',
'domain' => 'domaine',
'logo' => 'logo',
'url' => 'lien',
'url_claim' => 'lien réclamation',
'newsletter_email' => 'adresse email pour la newsletter',
],
];

View file

@ -89,8 +89,31 @@
</div>
</fieldset>
</div>
{{-- NEWSLETTER_EMAIL --}}
<div class="col-12 col-md-6 mb-3">
<fieldset class="h-100 border p-3">
<legend>{{ str(__('validation.custom.newsletter_email'))->ucfirst() }}</legend>
<div class="row">
<div class="col-12 mb-3">
@include('bo.partials.inputs.varchar', [
'id' => 'brandNewsletterEmail',
'label' => __(':field de la :model', [
'field' => str(__('validation.custom.newsletter_email'))->ucfirst(),
'model' => str(__('models.classes.brands'))->singular(),
]),
'hasWordCounter' => true,
'name' => 'newsletter_email',
'required' => false,
'model' => $brandModel ?? null,
'placeholder' => 'no-reply@example.com',
'helper' => __('Adresse email utilisée pour l\'envoi de la newsletter.'),
])
</div>
</div>
</fieldset>
</div>
{{-- IMAGE --}}
<div class="col-12 mb-3">
<div class="col-12 col-md-6 mb-3">
<fieldset class="h-100 border p-3">
<legend>{{ str(__('validation.attributes.image'))->ucfirst() }}</legend>
<div class="row">

View file

@ -58,10 +58,16 @@
</div>
</div>
<div class="row justify-content-center mt-4">
<div class="col-lg-6 col-md-10 col-12 text-center">
<button class="btn btn-primary" data-bs-tooltip="tooltip" type="submit"
<div class="col-lg-6 col-md-10 col-12 text-center text-lg-end">
<button class="btn btn-primary" data-bs-tooltip="tooltip" type="submit" name="action" value="export"
title="{{ __('Cliquer pour exporter la liste de client pour une newsletter') }}">
{{ __('Exporter') }}
{{ __('Exporter au format CSV') }}
</button>
</div>
<div class="col-lg-6 col-md-10 col-12 text-center text-lg-start">
<button class="btn btn-success" data-bs-tooltip="tooltip" type="submit" name="action" value="create_mailjet"
title="{{ __('Cliquer pour importer la liste de client sur mailjet') }}">
{{ __('Importer sur Mailjet') }}
</button>
</div>
</div>

View file

@ -15,6 +15,7 @@ use App\Http\Controllers\Api\V4\CustomerController as V4CustomerController;
use App\Http\Controllers\Api\V4\OptinController as V4OptinController;
use App\Http\Controllers\Api\V4\QuestionController as V4QuestionController;
use App\Http\Controllers\Api\V4\StatsController as V4StatsController;
use App\Http\Controllers\Api\WebhookController;
use Illuminate\Support\Facades\Route;
/*
@ -140,3 +141,5 @@ Route::prefix('v4')->name('apiV4.')->group(function () {
Route::post('/claim', [V4ClaimController::class, 'store'])->name('claim.store');
});
});
Route::post('/webhook/mailjet', [WebhookController::class, 'handleMailjetWebhook'])->name('webhook.mailjet');