diff --git a/src/Mail/Console/MakeMail.php b/src/Mail/Console/MakeMail.php
new file mode 100644
index 00000000..901bdf63
--- /dev/null
+++ b/src/Mail/Console/MakeMail.php
@@ -0,0 +1,122 @@
+addArgument('name', InputArgument::REQUIRED, 'The name of the mailable class');
+
+ $this->addOption('force', 'f', InputOption::VALUE_NONE, 'Force to create mailable');
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $this->input = $input;
+
+ $name = $this->input->getArgument('name');
+ $force = $this->input->getOption('force');
+
+ $namespace = explode(DIRECTORY_SEPARATOR, $name);
+ $className = array_pop($namespace);
+ $fileName = $this->getCustomFileName() ?? $className;
+
+ $filePath = $this->preparePath($namespace) . DIRECTORY_SEPARATOR . "{$fileName}.php";
+ $namespaceString = $this->prepareNamespace($namespace);
+
+ if (File::exists($filePath) && ! $force) {
+ $output->writeln(["{$this->commonName()} already exists!", self::EMPTY_LINE]);
+
+ return Command::SUCCESS;
+ }
+
+ $viewName = Str::snake($className);
+ $viewDotPath = empty($namespace)
+ ? $viewName
+ : implode('.', array_map('strtolower', $namespace)) . ".{$viewName}";
+
+ $stub = $this->getStubContent();
+ $stub = str_replace(['{namespace}', '{name}', '{view}'], [$namespaceString, $className, $viewDotPath], $stub);
+
+ File::put($filePath, $stub);
+
+ $outputPath = str_replace(base_path(), '', $filePath);
+
+ $output->writeln(["{$this->commonName()} [{$outputPath}] successfully generated!", self::EMPTY_LINE]);
+
+ $this->createView($input, $output, $namespace, $viewName);
+
+ return Command::SUCCESS;
+ }
+
+ protected function outputDirectory(): string
+ {
+ return 'app' . DIRECTORY_SEPARATOR . 'Mail';
+ }
+
+ protected function commonName(): string
+ {
+ return 'Mailable';
+ }
+
+ protected function stub(): string
+ {
+ return 'mailable.stub';
+ }
+
+ protected function createView(InputInterface $input, OutputInterface $output, array $namespace, string $viewName): void
+ {
+ $force = $input->getOption('force');
+
+ $viewPath = base_path('resources' . DIRECTORY_SEPARATOR . 'views' . DIRECTORY_SEPARATOR . 'emails');
+ $this->checkDirectory($viewPath);
+
+ foreach ($namespace as $directory) {
+ $viewPath .= DIRECTORY_SEPARATOR . strtolower($directory);
+ $this->checkDirectory($viewPath);
+ }
+
+ $viewFilePath = $viewPath . DIRECTORY_SEPARATOR . "{$viewName}.php";
+
+ if (File::exists($viewFilePath) && ! $force) {
+ $output->writeln(["View already exists!", self::EMPTY_LINE]);
+
+ return;
+ }
+
+ $viewStub = $this->getViewStubContent();
+ $viewStub = str_replace('{title}', ucwords(str_replace('_', ' ', $viewName)), $viewStub);
+
+ File::put($viewFilePath, $viewStub);
+
+ $outputPath = str_replace(base_path(), '', $viewFilePath);
+
+ $output->writeln(["View [{$outputPath}] successfully generated!", self::EMPTY_LINE]);
+ }
+
+ protected function getViewStubContent(): string
+ {
+ $path = dirname(__DIR__, 2)
+ . DIRECTORY_SEPARATOR . 'stubs'
+ . DIRECTORY_SEPARATOR . 'mail-view.stub';
+
+ return File::get($path);
+ }
+}
diff --git a/src/Mail/MailServiceProvider.php b/src/Mail/MailServiceProvider.php
index a95e9f78..2081415f 100644
--- a/src/Mail/MailServiceProvider.php
+++ b/src/Mail/MailServiceProvider.php
@@ -4,6 +4,7 @@
namespace Phenix\Mail;
+use Phenix\Mail\Console\MakeMail;
use Phenix\Providers\ServiceProvider;
class MailServiceProvider extends ServiceProvider
@@ -19,4 +20,11 @@ public function register(): void
{
$this->bind(MailManager::class)->setShared(true);
}
+
+ public function boot(): void
+ {
+ $this->commands([
+ MakeMail::class,
+ ]);
+ }
}
diff --git a/src/stubs/mail-view.stub b/src/stubs/mail-view.stub
new file mode 100644
index 00000000..b329a7ba
--- /dev/null
+++ b/src/stubs/mail-view.stub
@@ -0,0 +1,14 @@
+
+
+
+
+ {title}
+
+
+
+
+ {title}
+ Your email content goes here.
+
+
+
diff --git a/src/stubs/mailable.stub b/src/stubs/mailable.stub
new file mode 100644
index 00000000..af7bcda5
--- /dev/null
+++ b/src/stubs/mailable.stub
@@ -0,0 +1,16 @@
+view('emails.{view}')
+ ->subject('Subject here');
+ }
+}
diff --git a/tests/Unit/Mail/Console/MakeMailCommandTest.php b/tests/Unit/Mail/Console/MakeMailCommandTest.php
new file mode 100644
index 00000000..313e437f
--- /dev/null
+++ b/tests/Unit/Mail/Console/MakeMailCommandTest.php
@@ -0,0 +1,186 @@
+expect(
+ exists: fn (string $path) => false,
+ get: function (string $path) {
+ if (str_contains($path, 'mailable.stub')) {
+ return "view('emails.{view}')\n ->subject('Subject here');\n }\n}\n";
+ }
+ if (str_contains($path, 'mail-view.stub')) {
+ return "\n\n{title}\n{title}
\n\n";
+ }
+
+ return '';
+ },
+ put: function (string $path, string $content) {
+ if (str_contains($path, 'app/Mail')) {
+ expect($path)->toContain('app' . DIRECTORY_SEPARATOR . 'Mail' . DIRECTORY_SEPARATOR . 'WelcomeMail.php');
+ expect($content)->toContain('namespace App\Mail');
+ expect($content)->toContain('class WelcomeMail extends Mailable');
+ expect($content)->toContain("->view('emails.welcome_mail')");
+ }
+ if (str_contains($path, 'resources/views/emails')) {
+ expect($path)->toContain('resources' . DIRECTORY_SEPARATOR . 'views' . DIRECTORY_SEPARATOR . 'emails' . DIRECTORY_SEPARATOR . 'welcome_mail.php');
+ expect($content)->toContain('Welcome Mail');
+ }
+
+ return true;
+ },
+ createDirectory: function (string $path): void {
+ // ..
+ }
+ );
+
+ $this->app->swap(File::class, $mock);
+
+ /** @var \Symfony\Component\Console\Tester\CommandTester $command */
+ $command = $this->phenix('make:mail', [
+ 'name' => 'WelcomeMail',
+ ]);
+
+ $command->assertCommandIsSuccessful();
+
+ $display = $command->getDisplay();
+ expect($display)->toContain('Mailable [app/Mail/WelcomeMail.php] successfully generated!');
+ expect($display)->toContain('View [resources/views/emails/welcome_mail.php] successfully generated!');
+});
+
+it('does not create the mailable because it already exists', function (): void {
+ $mock = Mock::of(File::class)->expect(
+ exists: fn (string $path) => str_contains($path, 'app/Mail'),
+ );
+
+ $this->app->swap(File::class, $mock);
+
+ /** @var \Symfony\Component\Console\Tester\CommandTester $command */
+ $command = $this->phenix('make:mail', [
+ 'name' => 'WelcomeMail',
+ ]);
+
+ $command->assertCommandIsSuccessful();
+
+ expect($command->getDisplay())->toContain('Mailable already exists!');
+});
+
+it('creates mailable with force option when it already exists', function (): void {
+ $mock = Mock::of(File::class)->expect(
+ exists: fn (string $path) => str_contains($path, 'app/Mail'),
+ get: function (string $path) {
+ if (str_contains($path, 'mailable.stub')) {
+ return "view('emails.{view}')\n ->subject('Subject here');\n }\n}\n";
+ }
+ if (str_contains($path, 'mail-view.stub')) {
+ return "\n\n{title}\n{title}
\n\n";
+ }
+
+ return '';
+ },
+ put: fn (string $path, string $content) => true,
+ createDirectory: function (string $path): void {
+ // ..
+ }
+ );
+
+ $this->app->swap(File::class, $mock);
+
+ /** @var \Symfony\Component\Console\Tester\CommandTester $command */
+ $command = $this->phenix('make:mail', [
+ 'name' => 'WelcomeMail',
+ '--force' => true,
+ ]);
+
+ $command->assertCommandIsSuccessful();
+
+ $display = $command->getDisplay();
+ expect($display)->toContain('Mailable [app/Mail/WelcomeMail.php] successfully generated!');
+ expect($display)->toContain('View [resources/views/emails/welcome_mail.php] successfully generated!');
+});
+
+it('creates mailable successfully in nested namespace', function (): void {
+ $mock = Mock::of(File::class)->expect(
+ exists: fn (string $path) => false,
+ get: function (string $path) {
+ if (str_contains($path, 'mailable.stub')) {
+ return "view('emails.{view}')\n ->subject('Subject here');\n }\n}\n";
+ }
+ if (str_contains($path, 'mail-view.stub')) {
+ return "\n\n{title}\n{title}
\n\n";
+ }
+
+ return '';
+ },
+ put: function (string $path, string $content) {
+ if (str_contains($path, 'app/Mail')) {
+ expect($path)->toContain('app' . DIRECTORY_SEPARATOR . 'Mail' . DIRECTORY_SEPARATOR . 'Auth' . DIRECTORY_SEPARATOR . 'PasswordReset.php');
+ expect($content)->toContain('namespace App\Mail\Auth');
+ expect($content)->toContain('class PasswordReset extends Mailable');
+ expect($content)->toContain("->view('emails.auth.password_reset')");
+ }
+ if (str_contains($path, 'resources/views/emails')) {
+ expect($path)->toContain('resources' . DIRECTORY_SEPARATOR . 'views' . DIRECTORY_SEPARATOR . 'emails' . DIRECTORY_SEPARATOR . 'auth' . DIRECTORY_SEPARATOR . 'password_reset.php');
+ }
+
+ return true;
+ },
+ createDirectory: function (string $path): void {
+ // ..
+ }
+ );
+
+ $this->app->swap(File::class, $mock);
+
+ /** @var \Symfony\Component\Console\Tester\CommandTester $command */
+ $command = $this->phenix('make:mail', [
+ 'name' => 'Auth/PasswordReset',
+ ]);
+
+ $command->assertCommandIsSuccessful();
+
+ $display = $command->getDisplay();
+ expect($display)->toContain('Mailable [app/Mail/Auth/PasswordReset.php] successfully generated!');
+ expect($display)->toContain('View [resources/views/emails/auth/password_reset.php] successfully generated!');
+});
+
+it('does not create view when it already exists but creates mailable', function (): void {
+ $mock = Mock::of(File::class)->expect(
+ exists: function (string $path) {
+ return str_contains($path, 'resources/views/emails');
+ },
+ get: function (string $path) {
+ if (str_contains($path, 'mailable.stub')) {
+ return "view('emails.{view}')\n ->subject('Subject here');\n }\n}\n";
+ }
+
+ return '';
+ },
+ put: function (string $path, string $content) {
+ if (str_contains($path, 'app/Mail')) {
+ return true;
+ }
+
+ return false;
+ },
+ createDirectory: function (string $path): void {
+ // ..
+ }
+ );
+
+ $this->app->swap(File::class, $mock);
+
+ /** @var \Symfony\Component\Console\Tester\CommandTester $command */
+ $command = $this->phenix('make:mail', [
+ 'name' => 'WelcomeMail',
+ ]);
+
+ $command->assertCommandIsSuccessful();
+
+ $display = $command->getDisplay();
+ expect($display)->toContain('Mailable [app/Mail/WelcomeMail.php] successfully generated!');
+ expect($display)->toContain('View already exists!');
+});