diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bd9f04a..2b0d270 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -109,11 +109,6 @@ jobs: --health-timeout=5s --health-retries=3 - mailhog: - image: mailhog/mailhog - ports: - - 1025:1025 - steps: - name: Checkout plugin repository uses: actions/checkout@v6 @@ -151,7 +146,7 @@ jobs: DATABASE_URL: ${{ matrix.database == 'mysql' && 'mysql://root:root@127.0.0.1:3306/eccube_test' || 'postgresql://postgres:postgres@127.0.0.1:5432/eccube_test' }} DATABASE_SERVER_VERSION: ${{ matrix.database == 'mysql' && '8.0' || '15' }} DATABASE_CHARSET: ${{ matrix.database == 'mysql' && 'utf8mb4' || 'UTF8' }} - MAILER_DSN: 'smtp://127.0.0.1:1025' + MAILER_DSN: 'null://null' run: | php bin/console doctrine:database:create --if-not-exists php bin/console doctrine:schema:create @@ -167,7 +162,7 @@ jobs: DATABASE_URL: ${{ matrix.database == 'mysql' && 'mysql://root:root@127.0.0.1:3306/eccube_test' || 'postgresql://postgres:postgres@127.0.0.1:5432/eccube_test' }} DATABASE_SERVER_VERSION: ${{ matrix.database == 'mysql' && '8.0' || '15' }} DATABASE_CHARSET: ${{ matrix.database == 'mysql' && 'utf8mb4' || 'UTF8' }} - MAILER_DSN: 'smtp://127.0.0.1:1025' + MAILER_DSN: 'null://null' run: | vendor/bin/phpunit --exclude-group cache-clear,cache-clear-install,update-schema-doctrine,plugin-service app/Plugin/StockAlertMail/Tests/ diff --git a/Command/StockAlertCommand.php b/Command/StockAlertCommand.php index ba82c5e..09dd7c9 100644 --- a/Command/StockAlertCommand.php +++ b/Command/StockAlertCommand.php @@ -54,7 +54,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); - $config = $this->configRepository->findOneBy([]); + $config = $this->configRepository->find(1); if ($config === null) { $io->error($this->translator->trans('stock_alert_mail.command.config_not_found')); @@ -71,6 +71,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int ->andWhere('pc.visible = true') ->andWhere('p.Status = :status') ->setParameter('status', ProductStatus::DISPLAY_SHOW) + ->orderBy('p.id', 'ASC') ->getQuery() ->getResult(); @@ -108,12 +109,12 @@ protected function execute(InputInterface $input, OutputInterface $output): int $io->info($this->translator->trans('stock_alert_mail.command.alert_items_found', ['%count%' => count($newAlertItems)])); // メール送信 - $BaseInfo = $this->mailBuilder->getBaseInfo(); - $toEmails = $this->mailBuilder->resolveToEmails($config); - $body = $this->mailBuilder->buildMailBody($config, $newAlertItems, $threshold); - $subject = $this->mailBuilder->buildMailSubject($config); - try { + $BaseInfo = $this->mailBuilder->getBaseInfo(); + $toEmails = $this->mailBuilder->resolveToEmails($config); + $body = $this->mailBuilder->buildMailBody($newAlertItems, $threshold); + $subject = $this->mailBuilder->buildMailSubject(); + $message = (new Email()) ->subject($subject) ->from(new Address($BaseInfo->getEmail01(), $BaseInfo->getShopName())) @@ -135,7 +136,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->entityManager->flush(); $io->success($this->translator->trans('stock_alert_mail.command.mail_sent', ['%emails%' => implode(', ', $toEmails)])); - } catch (\Exception $e) { + } catch (\Throwable $e) { $io->error($this->translator->trans('stock_alert_mail.command.mail_failed', ['%message%' => $e->getMessage()])); return Command::FAILURE; diff --git a/Controller/Admin/StockAlertConfigController.php b/Controller/Admin/StockAlertConfigController.php index 39975ea..08da3fc 100644 --- a/Controller/Admin/StockAlertConfigController.php +++ b/Controller/Admin/StockAlertConfigController.php @@ -17,15 +17,21 @@ use Plugin\StockAlertMail\Entity\StockAlertConfig; use Plugin\StockAlertMail\Form\Type\Admin\StockAlertConfigType; use Plugin\StockAlertMail\Repository\StockAlertConfigRepository; +use Plugin\StockAlertMail\Service\StockAlertMailBuilder; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Template; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Mailer\MailerInterface; +use Symfony\Component\Mime\Address; +use Symfony\Component\Mime\Email; use Symfony\Component\Routing\Annotation\Route; class StockAlertConfigController extends AbstractController { public function __construct( private readonly StockAlertConfigRepository $configRepository, + private readonly StockAlertMailBuilder $mailBuilder, + private readonly MailerInterface $mailer, ) { } @@ -36,7 +42,7 @@ public function __construct( */ public function index(Request $request): array|Response { - $config = $this->configRepository->findOneBy([]); + $config = $this->configRepository->find(1); if ($config === null) { $config = new StockAlertConfig(); $config->setCreateDate(new \DateTime()); @@ -59,4 +65,48 @@ public function index(Request $request): array|Response 'form' => $form->createView(), ]; } + + /** + * @Route("/%eccube_admin_route%/plugin/stock-alert/config/send-test", name="stock_alert_mail_admin_config_send_test", methods={"POST"}) + */ + public function sendTest(Request $request): Response + { + if (!$this->isCsrfTokenValid('stock_alert_mail_send_test', $request->request->get('_token'))) { + $this->addError('admin.common.csrf_error', 'admin'); + + return $this->redirectToRoute('stock_alert_mail_admin_config'); + } + + $config = $this->configRepository->find(1); + if ($config === null) { + $this->addError('stock_alert_mail.admin.config.send_test.config_not_found', 'admin'); + + return $this->redirectToRoute('stock_alert_mail_admin_config'); + } + + $BaseInfo = $this->mailBuilder->getBaseInfo(); + $toEmails = $this->mailBuilder->resolveToEmails($config); + $dummyItems = $this->mailBuilder->createDummyItems(); + $subject = '[TEST] '.$this->mailBuilder->buildMailSubject(); + $body = $this->mailBuilder->buildMailBody($dummyItems, $config->getThreshold()); + + try { + $message = (new Email()) + ->subject($subject) + ->from(new Address($BaseInfo->getEmail01(), $BaseInfo->getShopName())) + ->text($body); + + foreach ($toEmails as $email) { + $message->addTo($email); + } + + $this->mailer->send($message); + + $this->addSuccess($this->translator->trans('stock_alert_mail.admin.config.send_test.success', ['%emails%' => implode(', ', $toEmails)]), 'admin'); + } catch (\Exception $e) { + $this->addError($this->translator->trans('stock_alert_mail.admin.config.send_test.failed', ['%message%' => $e->getMessage()]), 'admin'); + } + + return $this->redirectToRoute('stock_alert_mail_admin_config'); + } } diff --git a/Controller/Admin/StockAlertMailTemplateController.php b/Controller/Admin/StockAlertMailTemplateController.php deleted file mode 100644 index eab44d9..0000000 --- a/Controller/Admin/StockAlertMailTemplateController.php +++ /dev/null @@ -1,112 +0,0 @@ -configRepository->findOneBy([]); - if ($config === null) { - $config = new StockAlertConfig(); - $config->setCreateDate(new \DateTime()); - } - - $form = $this->createForm(StockAlertMailTemplateType::class, $config); - $form->handleRequest($request); - - if ($form->isSubmitted() && $form->isValid()) { - $config->setUpdateDate(new \DateTime()); - $this->entityManager->persist($config); - $this->entityManager->flush(); - - $this->addSuccess('stock_alert_mail.admin.config.save_success', 'admin'); - - return $this->redirectToRoute('stock_alert_mail_admin_mail_template'); - } - - return [ - 'form' => $form->createView(), - ]; - } - - /** - * @Route("/%eccube_admin_route%/plugin/stock-alert/mail-template/send-test", name="stock_alert_mail_admin_mail_template_send_test", methods={"POST"}) - */ - public function sendTest(Request $request): Response - { - if (!$this->isCsrfTokenValid('stock_alert_mail_send_test', $request->request->get('_token'))) { - $this->addError('admin.common.csrf_error', 'admin'); - - return $this->redirectToRoute('stock_alert_mail_admin_mail_template'); - } - - $config = $this->configRepository->findOneBy([]); - if ($config === null) { - $this->addError('stock_alert_mail.admin.mail_template.send_test.config_not_found', 'admin'); - - return $this->redirectToRoute('stock_alert_mail_admin_mail_template'); - } - - $BaseInfo = $this->mailBuilder->getBaseInfo(); - $dummyItems = $this->mailBuilder->createDummyItems(); - $toEmails = $this->mailBuilder->resolveToEmails($config); - $subject = '[TEST] '.$this->mailBuilder->buildMailSubject($config); - $body = $this->mailBuilder->buildMailBody($config, $dummyItems, $config->getThreshold()); - - try { - $message = (new Email()) - ->subject($subject) - ->from(new Address($BaseInfo->getEmail01(), $BaseInfo->getShopName())) - ->text($body); - - foreach ($toEmails as $email) { - $message->addTo($email); - } - - $this->mailer->send($message); - - $this->addSuccess($this->translator->trans('stock_alert_mail.admin.mail_template.send_test.success', ['%emails%' => implode(', ', $toEmails)]), 'admin'); - } catch (\Exception $e) { - $this->addError($this->translator->trans('stock_alert_mail.admin.mail_template.send_test.failed', ['%message%' => $e->getMessage()]), 'admin'); - } - - return $this->redirectToRoute('stock_alert_mail_admin_mail_template'); - } -} diff --git a/DoctrineMigrations/Version20260320000000.php b/DoctrineMigrations/Version20260320000000.php index 06fb4dd..4a480a7 100644 --- a/DoctrineMigrations/Version20260320000000.php +++ b/DoctrineMigrations/Version20260320000000.php @@ -32,8 +32,6 @@ public function up(Schema $schema): void $table->addColumn('id', 'integer', ['unsigned' => true, 'autoincrement' => true]); $table->addColumn('threshold', 'integer', ['default' => 5]); $table->addColumn('alert_emails', 'string', ['length' => 1000, 'notnull' => false]); - $table->addColumn('mail_subject', 'string', ['length' => 500, 'notnull' => false]); - $table->addColumn('mail_body', 'text', ['notnull' => false]); $table->addColumn('create_date', 'datetimetz'); $table->addColumn('update_date', 'datetimetz'); $table->setPrimaryKey(['id']); diff --git a/DoctrineMigrations/Version20260321000000.php b/DoctrineMigrations/Version20260321000000.php new file mode 100644 index 0000000..54a6a2e --- /dev/null +++ b/DoctrineMigrations/Version20260321000000.php @@ -0,0 +1,62 @@ +hasTable(self::TABLE)) { + return; + } + + $table = $schema->getTable(self::TABLE); + + if ($table->hasColumn('mail_subject')) { + $table->dropColumn('mail_subject'); + } + + if ($table->hasColumn('mail_body')) { + $table->dropColumn('mail_body'); + } + } + + public function down(Schema $schema): void + { + if (!$schema->hasTable(self::TABLE)) { + return; + } + + $table = $schema->getTable(self::TABLE); + + if (!$table->hasColumn('mail_subject')) { + $table->addColumn('mail_subject', 'string', ['length' => 500, 'notnull' => false]); + } + + if (!$table->hasColumn('mail_body')) { + $table->addColumn('mail_body', 'text', ['notnull' => false]); + } + } +} diff --git a/Entity/StockAlertConfig.php b/Entity/StockAlertConfig.php index aecf7f9..35e6826 100644 --- a/Entity/StockAlertConfig.php +++ b/Entity/StockAlertConfig.php @@ -29,9 +29,9 @@ class StockAlertConfig * * @ORM\Id * - * @ORM\GeneratedValue(strategy="IDENTITY") + * @ORM\GeneratedValue(strategy="NONE") */ - private $id; + private $id = 1; /** * 在庫アラート閾値(この数以下になったら通知) @@ -47,22 +47,6 @@ class StockAlertConfig */ private $alertEmails; - /** - * メール件名テンプレート(空の場合はデフォルトを使用) - * 使用可能プレースホルダー: {shop_name} - * - * @ORM\Column(name="mail_subject", type="string", length=500, nullable=true) - */ - private $mailSubject; - - /** - * メール本文テンプレート(空の場合はデフォルトを使用) - * 使用可能プレースホルダー: {shop_name}, {threshold}, {items} - * - * @ORM\Column(name="mail_body", type="text", nullable=true) - */ - private $mailBody; - /** * @ORM\Column(name="create_date", type="datetimetz") */ @@ -102,30 +86,6 @@ public function setAlertEmails($alertEmails) return $this; } - public function getMailSubject() - { - return $this->mailSubject; - } - - public function setMailSubject($mailSubject) - { - $this->mailSubject = $mailSubject; - - return $this; - } - - public function getMailBody() - { - return $this->mailBody; - } - - public function setMailBody($mailBody) - { - $this->mailBody = $mailBody; - - return $this; - } - public function getCreateDate() { return $this->createDate; diff --git a/Form/Type/Admin/StockAlertMailTemplateType.php b/Form/Type/Admin/StockAlertMailTemplateType.php deleted file mode 100644 index 3ff7a8d..0000000 --- a/Form/Type/Admin/StockAlertMailTemplateType.php +++ /dev/null @@ -1,63 +0,0 @@ -add('mailSubject', TextType::class, [ - 'label' => 'stock_alert_mail.form.mail_subject.label', - 'required' => false, - 'constraints' => [ - new Assert\Length(['max' => 500]), - new Assert\Regex([ - 'pattern' => '/[\r\n]/', - 'match' => false, - 'message' => '件名に改行を含めることはできません。', - ]), - ], - 'attr' => [ - 'placeholder' => 'stock_alert_mail.form.mail_subject.placeholder', - ], - ]) - ->add('mailBody', TextareaType::class, [ - 'label' => 'stock_alert_mail.form.mail_body.label', - 'required' => false, - 'constraints' => [ - new Assert\Length(['max' => 10000]), - ], - 'attr' => [ - 'rows' => 20, - 'style' => 'font-family: monospace;', - ], - ]); - } - - public function configureOptions(OptionsResolver $resolver): void - { - $resolver->setDefaults([ - 'data_class' => StockAlertConfig::class, - ]); - } -} diff --git a/Nav.php b/Nav.php index 247ffd8..dc0997e 100644 --- a/Nav.php +++ b/Nav.php @@ -31,10 +31,6 @@ public static function getNav(): array 'name' => 'stock_alert_mail.nav.config', 'url' => 'stock_alert_mail_admin_config', ], - 'stock_alert_mail_mail_template' => [ - 'name' => 'stock_alert_mail.nav.mail_template', - 'url' => 'stock_alert_mail_admin_mail_template', - ], 'stock_alert_mail_log' => [ 'name' => 'stock_alert_mail.nav.log', 'url' => 'stock_alert_mail_admin_log', diff --git a/PluginManager.php b/PluginManager.php index 572dcb3..485270c 100644 --- a/PluginManager.php +++ b/PluginManager.php @@ -14,21 +14,32 @@ namespace Plugin\StockAlertMail; use Doctrine\ORM\EntityManagerInterface; +use Eccube\Common\EccubeConfig; +use Eccube\Entity\MailTemplate; use Eccube\Plugin\AbstractPluginManager; use Plugin\StockAlertMail\Entity\StockAlertConfig; use Psr\Container\ContainerInterface; class PluginManager extends AbstractPluginManager { + /** dtb_mail_template に登録するファイル名 */ + public const MAIL_TEMPLATE_FILE_NAME = 'Mail/stock_alert.twig'; + + /** プラグインのデフォルトメール件名(送信時に "[ショップ名] " が先頭に付く) */ + private const MAIL_SUBJECT = '在庫アラート通知'; + public function enable(array $meta, ContainerInterface $container): void { $entityManager = $container->get('doctrine')->getManager(); $this->createInitialConfig($entityManager); + $this->createMailTemplate($entityManager, $container); } public function uninstall(array $meta, ContainerInterface $container): void { $entityManager = $container->get('doctrine')->getManager(); + $this->deleteMailTemplate($entityManager, $container); + $conn = $entityManager->getConnection(); $schemaManager = $conn->createSchemaManager(); @@ -45,8 +56,7 @@ private function createInitialConfig(EntityManagerInterface $entityManager): voi { $repository = $entityManager->getRepository(StockAlertConfig::class); - // 既に設定が存在する場合はスキップ - if ($repository->findOneBy([]) !== null) { + if ($repository->find(1) !== null) { return; } @@ -58,4 +68,81 @@ private function createInitialConfig(EntityManagerInterface $entityManager): voi $entityManager->persist($config); $entityManager->flush(); } + + private function createMailTemplate(EntityManagerInterface $entityManager, ContainerInterface $container): void + { + // まずテンプレート実体を保証(欠落時の自己修復) + $this->copyTwigTemplate($container); + + $repository = $entityManager->getRepository(MailTemplate::class); + + // 既に登録済みの場合はDB登録のみスキップ + if ($repository->findOneBy(['file_name' => self::MAIL_TEMPLATE_FILE_NAME]) !== null) { + return; + } + + $mailTemplate = new MailTemplate(); + $mailTemplate->setName('在庫アラートメール'); + $mailTemplate->setFileName(self::MAIL_TEMPLATE_FILE_NAME); + $mailTemplate->setMailSubject(self::MAIL_SUBJECT); + $mailTemplate->setCreateDate(new \DateTime()); + $mailTemplate->setUpdateDate(new \DateTime()); + + $entityManager->persist($mailTemplate); + $entityManager->flush(); + } + + private function deleteMailTemplate(EntityManagerInterface $entityManager, ContainerInterface $container): void + { + $repository = $entityManager->getRepository(MailTemplate::class); + $mailTemplate = $repository->findOneBy(['file_name' => self::MAIL_TEMPLATE_FILE_NAME]); + + if ($mailTemplate !== null) { + $entityManager->remove($mailTemplate); + $entityManager->flush(); + } + + $this->removeTwigTemplate($container); + } + + private function copyTwigTemplate(ContainerInterface $container): void + { + $targetPath = $this->getTwigTargetPath($container); + + if (file_exists($targetPath)) { + return; + } + + $sourcePath = __DIR__.'/Resource/template/Mail/stock_alert.twig'; + + if (!file_exists($sourcePath)) { + throw new \RuntimeException(sprintf('プラグインのテンプレートファイルが見つかりません: %s', $sourcePath)); + } + + $targetDir = dirname($targetPath); + if (!is_dir($targetDir) && !mkdir($targetDir, 0755, true)) { + throw new \RuntimeException(sprintf('テンプレートディレクトリの作成に失敗しました: %s', $targetDir)); + } + + if (!copy($sourcePath, $targetPath)) { + throw new \RuntimeException(sprintf('テンプレートのコピーに失敗しました: %s → %s', $sourcePath, $targetPath)); + } + } + + private function removeTwigTemplate(ContainerInterface $container): void + { + $targetPath = $this->getTwigTargetPath($container); + + if (file_exists($targetPath)) { + unlink($targetPath); + } + } + + private function getTwigTargetPath(ContainerInterface $container): string + { + /** @var EccubeConfig $eccubeConfig */ + $eccubeConfig = $container->get(EccubeConfig::class); + + return $eccubeConfig['eccube_theme_front_dir'].'/'.self::MAIL_TEMPLATE_FILE_NAME; + } } diff --git a/Resource/locale/messages.en.yaml b/Resource/locale/messages.en.yaml index 77e83f0..302047c 100644 --- a/Resource/locale/messages.en.yaml +++ b/Resource/locale/messages.en.yaml @@ -11,24 +11,21 @@ stock_alert_mail.admin.config.cron.note: The example above runs at every hour on stock_alert_mail.admin.config.unit: unit(s) stock_alert_mail.admin.config.save: Save stock_alert_mail.admin.config.save_success: Saved successfully. +stock_alert_mail.admin.config.send_test.button: Send Test Email +stock_alert_mail.admin.config.send_test.confirm: Send a test email? (Uses dummy product data) +stock_alert_mail.admin.config.send_test.success: 'Test email sent to %emails%.' +stock_alert_mail.admin.config.send_test.failed: 'Failed to send test email: %message%' +stock_alert_mail.admin.config.send_test.config_not_found: Settings not found. stock_alert_mail.admin.config.required: Required stock_alert_mail.admin.config.section.mail_template: Mail Template -stock_alert_mail.admin.config.mail_template.description: If left empty, the default template will be used. -stock_alert_mail.admin.config.mail_template.placeholders: Available placeholders -stock_alert_mail.admin.config.mail_template.placeholder.shop_name: Shop name -stock_alert_mail.admin.config.mail_template.placeholder.threshold: Threshold (units) -stock_alert_mail.admin.config.mail_template.placeholder.items: List of low-stock products -stock_alert_mail.admin.config.mail_template.subject_help: 'Default if empty: [{shop_name}] Stock Alert Notification' -stock_alert_mail.admin.config.mail_template.body_help: If empty, the default body will be used. +stock_alert_mail.admin.config.mail_template.description: The mail subject and body can be edited from the standard EC-CUBE mail settings. +stock_alert_mail.admin.config.mail_template.link: Open mail settings stock_alert_mail.form.threshold.label: Stock Alert Threshold stock_alert_mail.form.threshold.placeholder: 'e.g. 5' stock_alert_mail.form.threshold.help: Products at or below this quantity will trigger an email notification. stock_alert_mail.form.alert_emails.label: Notification Email Address(es) stock_alert_mail.form.alert_emails.placeholder: If empty, the store email address will be used. Separate multiple addresses with commas. -stock_alert_mail.form.mail_subject.label: Mail Subject -stock_alert_mail.form.mail_subject.placeholder: '[{shop_name}] Stock Alert Notification' -stock_alert_mail.form.mail_body.label: Mail Body stock_alert_mail.command.description: Notifies the administrator by email when stock falls below the threshold stock_alert_mail.command.config_not_found: Plugin configuration not found. Please enable the plugin. @@ -36,35 +33,21 @@ stock_alert_mail.command.no_alert_items: No new low-stock items found. stock_alert_mail.command.alert_items_found: 'Found %count% new low-stock item(s).' stock_alert_mail.command.mail_sent: 'Stock alert email sent to %emails%.' stock_alert_mail.command.mail_failed: 'Failed to send email: %message%' -stock_alert_mail.command.mail_subject: '[%shop_name%] Stock Alert Notification' stock_alert_mail.mail.greeting: 'Dear %shop_name% Administrator,' stock_alert_mail.mail.low_stock_intro: 'The following products have stock at or below the threshold (%threshold% units).' stock_alert_mail.mail.please_check: Please review and restock as needed. stock_alert_mail.mail.alert_title: 'Low Stock Items (%count% item(s))' +stock_alert_mail.mail.product_id: 'Product ID: %id%' +stock_alert_mail.mail.product_id_label: '(Product ID: %id%)' stock_alert_mail.mail.current_stock: 'Current Stock: %stock% unit(s)' stock_alert_mail.mail.threshold_label: 'Threshold: %threshold% unit(s)' stock_alert_mail.mail.restock_message: Please consider restocking these items. stock_alert_mail.nav.title: Stock Alert stock_alert_mail.nav.config: Settings -stock_alert_mail.nav.mail_template: Mail Template stock_alert_mail.nav.log: Send History -stock_alert_mail.admin.mail_template.title: Mail Template -stock_alert_mail.admin.mail_template.section.placeholders: Available Placeholders -stock_alert_mail.admin.mail_template.section.template: Edit Template -stock_alert_mail.admin.mail_template.placeholder.description: The following placeholders can be used in the template. -stock_alert_mail.admin.mail_template.placeholder.col.key: Placeholder -stock_alert_mail.admin.mail_template.placeholder.col.description: Description -stock_alert_mail.admin.mail_template.placeholder.col.subject: Usable in Subject - -stock_alert_mail.admin.mail_template.send_test.button: Send Test Email -stock_alert_mail.admin.mail_template.send_test.confirm: Send a test email? (Uses dummy product data) -stock_alert_mail.admin.mail_template.send_test.success: 'Test email sent to %emails%.' -stock_alert_mail.admin.mail_template.send_test.failed: 'Failed to send test email: %message%' -stock_alert_mail.admin.mail_template.send_test.config_not_found: Settings not found. Please save settings first. - stock_alert_mail.test.dummy_product_a: Sample Product A stock_alert_mail.test.dummy_product_b: Sample Product B stock_alert_mail.test.dummy_class1: Red diff --git a/Resource/locale/messages.ja.yaml b/Resource/locale/messages.ja.yaml index 82859db..50893c6 100644 --- a/Resource/locale/messages.ja.yaml +++ b/Resource/locale/messages.ja.yaml @@ -11,24 +11,21 @@ stock_alert_mail.admin.config.cron.note: 上記の例は毎時0分に実行し stock_alert_mail.admin.config.unit: 個 stock_alert_mail.admin.config.save: 保存 stock_alert_mail.admin.config.save_success: 保存しました。 +stock_alert_mail.admin.config.send_test.button: テストメール送信 +stock_alert_mail.admin.config.send_test.confirm: テストメールを送信しますか?(ダミーの商品データで送信されます) +stock_alert_mail.admin.config.send_test.success: 'テストメールを %emails% に送信しました。' +stock_alert_mail.admin.config.send_test.failed: 'テストメール送信に失敗しました: %message%' +stock_alert_mail.admin.config.send_test.config_not_found: 設定が見つかりません。 stock_alert_mail.admin.config.required: 必須 stock_alert_mail.admin.config.section.mail_template: メールテンプレート -stock_alert_mail.admin.config.mail_template.description: 空欄の場合はデフォルトのテンプレートを使用します。 -stock_alert_mail.admin.config.mail_template.placeholders: 使用可能なプレースホルダー -stock_alert_mail.admin.config.mail_template.placeholder.shop_name: 店舗名 -stock_alert_mail.admin.config.mail_template.placeholder.threshold: 閾値(個) -stock_alert_mail.admin.config.mail_template.placeholder.items: 在庫アラート対象商品の一覧 -stock_alert_mail.admin.config.mail_template.subject_help: '空欄の場合のデフォルト: [{shop_name}] 在庫アラート通知' -stock_alert_mail.admin.config.mail_template.body_help: 空欄の場合はデフォルトの本文を使用します。 +stock_alert_mail.admin.config.mail_template.description: メールの件名・本文はEC-CUBE標準のメール設定画面で編集できます。 +stock_alert_mail.admin.config.mail_template.link: メール設定を開く stock_alert_mail.form.threshold.label: 在庫アラート閾値 stock_alert_mail.form.threshold.placeholder: '例: 5' stock_alert_mail.form.threshold.help: この個数以下になった商品をメールで通知します。 stock_alert_mail.form.alert_emails.label: 通知先メールアドレス stock_alert_mail.form.alert_emails.placeholder: 空欄の場合は店舗設定のメールアドレスを使用します。複数の場合はカンマ区切り。 -stock_alert_mail.form.mail_subject.label: メール件名 -stock_alert_mail.form.mail_subject.placeholder: '[{shop_name}] 在庫アラート通知' -stock_alert_mail.form.mail_body.label: メール本文 stock_alert_mail.command.description: 在庫数が閾値以下の商品を管理者にメール通知します stock_alert_mail.command.config_not_found: プラグイン設定が見つかりません。プラグインを有効化してください。 @@ -36,35 +33,21 @@ stock_alert_mail.command.no_alert_items: 新規の在庫アラート対象商品 stock_alert_mail.command.alert_items_found: '%count% 件の新規在庫アラート対象商品が見つかりました。' stock_alert_mail.command.mail_sent: '在庫アラートメールを %emails% に送信しました。' stock_alert_mail.command.mail_failed: 'メール送信に失敗しました: %message%' -stock_alert_mail.command.mail_subject: '[%shop_name%] 在庫アラート通知' stock_alert_mail.mail.greeting: '%shop_name% 管理者様' stock_alert_mail.mail.low_stock_intro: '在庫数が閾値(%threshold%個)以下の商品があります。' stock_alert_mail.mail.please_check: ご確認をお願いします。 stock_alert_mail.mail.alert_title: '在庫アラート対象商品(%count%件)' +stock_alert_mail.mail.product_id: '商品ID: %id%' +stock_alert_mail.mail.product_id_label: '(商品ID: %id%)' stock_alert_mail.mail.current_stock: '現在の在庫数:%stock% 個' stock_alert_mail.mail.threshold_label: '閾値:%threshold% 個' stock_alert_mail.mail.restock_message: 在庫の補充をご検討ください。 stock_alert_mail.nav.title: 在庫アラート stock_alert_mail.nav.config: 設定 -stock_alert_mail.nav.mail_template: メールテンプレート stock_alert_mail.nav.log: 送信履歴 -stock_alert_mail.admin.mail_template.title: メールテンプレート -stock_alert_mail.admin.mail_template.section.placeholders: 使用可能なプレースホルダー -stock_alert_mail.admin.mail_template.section.template: テンプレート編集 -stock_alert_mail.admin.mail_template.placeholder.description: 以下のプレースホルダーをテンプレート内で使用できます。 -stock_alert_mail.admin.mail_template.placeholder.col.key: プレースホルダー -stock_alert_mail.admin.mail_template.placeholder.col.description: 内容 -stock_alert_mail.admin.mail_template.placeholder.col.subject: 件名で使用可 - -stock_alert_mail.admin.mail_template.send_test.button: テストメール送信 -stock_alert_mail.admin.mail_template.send_test.confirm: テストメールを送信しますか?(ダミーの商品データで送信されます) -stock_alert_mail.admin.mail_template.send_test.success: 'テストメールを %emails% に送信しました。' -stock_alert_mail.admin.mail_template.send_test.failed: 'テストメール送信に失敗しました: %message%' -stock_alert_mail.admin.mail_template.send_test.config_not_found: 設定が保存されていません。先に設定画面で保存してください。 - stock_alert_mail.test.dummy_product_a: サンプル商品A stock_alert_mail.test.dummy_product_b: サンプル商品B stock_alert_mail.test.dummy_class1: レッド diff --git a/Resource/template/Mail/stock_alert.twig b/Resource/template/Mail/stock_alert.twig index a004dde..4f3f7aa 100644 --- a/Resource/template/Mail/stock_alert.twig +++ b/Resource/template/Mail/stock_alert.twig @@ -7,11 +7,13 @@ {{ 'stock_alert_mail.mail.alert_title'|trans({'%count%': lowStockItems|length}) }} ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +{% set currentProductId = null %} {% for item in lowStockItems %} -■ {{ item.Product.name }}{% if item.hasClassCategory1 %} [{{ item.ClassCategory1.name }}{% if item.hasClassCategory2 %} / {{ item.ClassCategory2.name }}{% endif %}]{% endif %} - - {{ 'stock_alert_mail.mail.current_stock'|trans({'%stock%': item.stock}) }} - {{ 'stock_alert_mail.mail.threshold_label'|trans({'%threshold%': threshold}) }} +{% if item.Product.id != currentProductId %} +{% set currentProductId = item.Product.id %} +■ {{ item.Product.name }} {{ 'stock_alert_mail.mail.product_id_label'|trans({'%id%': item.Product.id}) }} +{% endif %} + - {% if item.hasClassCategory1 %}{{ item.ClassCategory1.name }}{% if item.hasClassCategory2 %} / {{ item.ClassCategory2.name }}{% endif %}{% else %}—{% endif %}:{{ 'stock_alert_mail.mail.current_stock'|trans({'%stock%': item.stock}) }} {% endfor %} ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ diff --git a/Resource/template/admin/config.twig b/Resource/template/admin/config.twig index b02819d..05b368a 100644 --- a/Resource/template/admin/config.twig +++ b/Resource/template/admin/config.twig @@ -51,6 +51,17 @@ +
+
+ {{ 'stock_alert_mail.admin.config.section.mail_template'|trans }} +
+
+

{{ 'stock_alert_mail.admin.config.mail_template.description'|trans }} + {{ 'stock_alert_mail.admin.config.mail_template.link'|trans }} +

+
+
+
{{ 'stock_alert_mail.admin.config.section.cron'|trans }} @@ -62,14 +73,24 @@
-
-
- +
+
+ +
+
+
+
+ +
+
diff --git a/Resource/template/admin/mail_template.twig b/Resource/template/admin/mail_template.twig deleted file mode 100644 index 3d57b3f..0000000 --- a/Resource/template/admin/mail_template.twig +++ /dev/null @@ -1,109 +0,0 @@ -{% extends '@admin/default_frame.twig' %} - -{% block title %}{{ 'stock_alert_mail.admin.mail_template.title'|trans }}{% endblock %} -{% block sub_title %}{{ 'stock_alert_mail.nav.title'|trans }}{% endblock %} - -{% block stylesheet %}{% endblock %} - -{% block main %} -
-
-
- -
-

{{ 'stock_alert_mail.admin.mail_template.title'|trans }}

-
- -
- {{ form_widget(form._token) }} - -
-
- {{ 'stock_alert_mail.admin.mail_template.section.placeholders'|trans }} -
-
-

{{ 'stock_alert_mail.admin.mail_template.placeholder.description'|trans }}

- - - - - - - - - - - - - - - - - - - - - - - - - -
{{ 'stock_alert_mail.admin.mail_template.placeholder.col.key'|trans }}{{ 'stock_alert_mail.admin.mail_template.placeholder.col.description'|trans }}{{ 'stock_alert_mail.admin.mail_template.placeholder.col.subject'|trans }}
{shop_name}{{ 'stock_alert_mail.admin.config.mail_template.placeholder.shop_name'|trans }}
{threshold}{{ 'stock_alert_mail.admin.config.mail_template.placeholder.threshold'|trans }}
{items}{{ 'stock_alert_mail.admin.config.mail_template.placeholder.items'|trans }}
-
-
- -
-
- {{ 'stock_alert_mail.admin.mail_template.section.template'|trans }} -
-
- -
-
- -
-
- {{ form_widget(form.mailSubject, {'attr': {'class': 'form-control'}}) }} - {{ 'stock_alert_mail.admin.config.mail_template.subject_help'|trans }} - {{ form_errors(form.mailSubject) }} -
-
- -
-
- -
-
- {{ form_widget(form.mailBody, {'attr': {'class': 'form-control'}}) }} - {{ 'stock_alert_mail.admin.config.mail_template.body_help'|trans }} - {{ form_errors(form.mailBody) }} -
-
- -
-
- -
-
- -
-
- -
- -
- -
-
- -
-
-
- -
-
-
-{% endblock %} diff --git a/Service/StockAlertMailBuilder.php b/Service/StockAlertMailBuilder.php index 3c212b6..f0d4795 100644 --- a/Service/StockAlertMailBuilder.php +++ b/Service/StockAlertMailBuilder.php @@ -15,10 +15,13 @@ use Eccube\Entity\BaseInfo; use Eccube\Entity\ClassCategory; +use Eccube\Entity\MailTemplate; use Eccube\Entity\Product; use Eccube\Entity\ProductClass; use Eccube\Repository\BaseInfoRepository; +use Eccube\Repository\MailTemplateRepository; use Plugin\StockAlertMail\Entity\StockAlertConfig; +use Plugin\StockAlertMail\PluginManager; use Symfony\Contracts\Translation\TranslatorInterface; use Twig\Environment; @@ -28,6 +31,7 @@ class StockAlertMailBuilder public function __construct( private readonly BaseInfoRepository $baseInfoRepository, + private readonly MailTemplateRepository $mailTemplateRepository, private readonly TranslatorInterface $translator, private readonly Environment $twig, ) { @@ -55,52 +59,42 @@ public function resolveToEmails(StockAlertConfig $config): array return [$this->BaseInfo->getEmail01()]; } - public function buildMailSubject(StockAlertConfig $config): string + public function buildMailSubject(): string { - if (!empty($config->getMailSubject())) { - $subject = strtr($config->getMailSubject(), [ - '{shop_name}' => $this->BaseInfo->getShopName(), - ]); - } else { - $subject = $this->translator->trans('stock_alert_mail.command.mail_subject', ['%shop_name%' => $this->BaseInfo->getShopName()]); + $mailTemplate = $this->findMailTemplate(); + + $subjectSuffix = $mailTemplate !== null + ? trim((string) $mailTemplate->getMailSubject()) + : ''; + if ($subjectSuffix === '') { + $subjectSuffix = '在庫アラート通知'; } - // プレースホルダー展開後の改行をサニタイズ(ヘッダーインジェクション対策) + $subject = '['.$this->BaseInfo->getShopName().'] '.$subjectSuffix; + + // 改行をサニタイズ(ヘッダーインジェクション対策) return preg_replace('/[\r\n]+/', ' ', $subject); } - public function buildMailBody(StockAlertConfig $config, array $items, int $threshold): string + public function buildMailBody(array $items, int $threshold): string { - $customBody = $config->getMailBody(); - if (!empty($customBody)) { - $itemLines = []; - foreach ($items as $productClass) { - $name = $productClass->getProduct()->getName(); - if ($productClass->hasClassCategory1()) { - $name .= ' ['.$productClass->getClassCategory1()->getName(); - if ($productClass->hasClassCategory2()) { - $name .= ' / '.$productClass->getClassCategory2()->getName(); - } - $name .= ']'; - } - $itemLines[] = '■ '.$name; - $itemLines[] = ' '.$this->translator->trans('stock_alert_mail.mail.current_stock', ['%stock%' => $productClass->getStock()]); - $itemLines[] = ' '.$this->translator->trans('stock_alert_mail.mail.threshold_label', ['%threshold%' => $threshold]); - $itemLines[] = ''; - } - - return strtr($customBody, [ - '{shop_name}' => $this->BaseInfo->getShopName(), - '{threshold}' => $threshold, - '{items}' => implode("\n", $itemLines), - ]); - } - - return $this->twig->render('@StockAlertMail/Mail/stock_alert.twig', [ + $templateParams = [ 'BaseInfo' => $this->BaseInfo, 'lowStockItems' => $items, 'threshold' => $threshold, - ]); + ]; + + $mailTemplate = $this->findMailTemplate(); + + if ($mailTemplate !== null) { + try { + return $this->twig->render($mailTemplate->getFileName(), $templateParams); + } catch (\Twig\Error\LoaderError $e) { + // テーマ側テンプレートが欠落している場合はプラグイン付属テンプレートへフォールバック + } + } + + return $this->twig->render('@StockAlertMail/Mail/stock_alert.twig', $templateParams); } /** @@ -135,4 +129,11 @@ public function createDummyItems(): array return [$pcA, $pcB]; } + + private function findMailTemplate(): ?MailTemplate + { + return $this->mailTemplateRepository->findOneBy([ + 'file_name' => PluginManager::MAIL_TEMPLATE_FILE_NAME, + ]); + } } diff --git a/Tests/PluginManagerTest.php b/Tests/PluginManagerTest.php index 0e629de..44775d3 100644 --- a/Tests/PluginManagerTest.php +++ b/Tests/PluginManagerTest.php @@ -14,6 +14,8 @@ namespace Plugin\StockAlertMail\Tests; use Doctrine\DBAL\Schema\Schema; +use Eccube\Common\EccubeConfig; +use Eccube\Entity\MailTemplate; use Eccube\Tests\EccubeTestCase; use Plugin\StockAlertMail\Entity\StockAlertConfig; use Plugin\StockAlertMail\PluginManager; @@ -91,7 +93,7 @@ public function testEnable() { $this->pluginManager->enable([], static::getContainer()); - $config = $this->configRepository->findOneBy([]); + $config = $this->configRepository->find(1); $this->assertNotNull($config, '設定が作成されていること'); $this->assertSame(5, $config->getThreshold(), '初期閾値が5であること'); } @@ -178,7 +180,7 @@ public function testDisable() // disable() はデータ削除の独自ロジックを持たないが、エラーが発生せず設定が残ることを確認する $this->pluginManager->disable([], static::getContainer()); - $config = $this->configRepository->findOneBy([]); + $config = $this->configRepository->find(1); $this->assertNotNull($config, 'disable後も設定が保持されること'); $this->assertSame(42, $config->getThreshold(), '設定値が変更されていないこと'); } @@ -206,11 +208,43 @@ public function testUninstallThenReinstall() // 再enableできること $this->pluginManager->enable([], static::getContainer()); - $config = $this->configRepository->findOneBy([]); + $config = $this->configRepository->find(1); $this->assertNotNull($config, '再enable後に設定が作成されること'); $this->assertSame(5, $config->getThreshold(), '初期閾値が5であること'); } + /** + * enable: DB行があるがTwigファイルが欠損している場合、ファイルが自己修復されること。 + */ + public function testEnableRestoresMissingTwigTemplate() + { + // まず通常の enable で MailTemplate + Twig ファイルを作成 + $this->pluginManager->enable([], static::getContainer()); + + /** @var EccubeConfig $eccubeConfig */ + $eccubeConfig = static::getContainer()->get(EccubeConfig::class); + $twigPath = $eccubeConfig['eccube_theme_front_dir'].'/'.PluginManager::MAIL_TEMPLATE_FILE_NAME; + + // DB行は残したまま Twig ファイルだけ削除(半壊状態を再現) + $mailTemplate = $this->entityManager->getRepository(MailTemplate::class) + ->findOneBy(['file_name' => PluginManager::MAIL_TEMPLATE_FILE_NAME]); + $this->assertNotNull($mailTemplate, 'MailTemplateのDB行が存在すること'); + $this->assertFileExists($twigPath, 'enable後にTwigファイルが存在すること'); + + unlink($twigPath); + $this->assertFileDoesNotExist($twigPath, 'Twigファイルが削除されたこと'); + + // 再度 enable → Twig ファイルが復元されること + $this->pluginManager->enable([], static::getContainer()); + + $this->assertFileExists($twigPath, 'enable後にTwigファイルが自己修復されること'); + + // クリーンアップ + if (file_exists($twigPath)) { + unlink($twigPath); + } + } + /** * uninstallテストでテーブルが削除されている場合に再作成する。 */ diff --git a/Tests/StockAlertCommandTest.php b/Tests/StockAlertCommandTest.php index 23b2628..11676e8 100644 --- a/Tests/StockAlertCommandTest.php +++ b/Tests/StockAlertCommandTest.php @@ -14,9 +14,11 @@ namespace Plugin\StockAlertMail\Tests; use Eccube\Entity\BaseInfo; +use Eccube\Entity\MailTemplate; use Eccube\Tests\EccubeTestCase; use Plugin\StockAlertMail\Entity\StockAlertConfig; use Plugin\StockAlertMail\Entity\StockAlertLog; +use Plugin\StockAlertMail\PluginManager; use Plugin\StockAlertMail\Repository\StockAlertConfigRepository; use Plugin\StockAlertMail\Repository\StockAlertLogRepository; use Symfony\Bundle\FrameworkBundle\Console\Application; @@ -37,6 +39,9 @@ class StockAlertCommandTest extends EccubeTestCase /** @var CommandTester */ private $commandTester; + /** @var int */ + private $configId; + protected function setUp(): void { parent::setUp(); @@ -46,6 +51,15 @@ protected function setUp(): void // eccube:plugin:install で作成された初期設定も含め全データをクリーンアップ $this->entityManager->createQuery('DELETE FROM Plugin\StockAlertMail\Entity\StockAlertLog l')->execute(); $this->entityManager->createQuery('DELETE FROM Plugin\StockAlertMail\Entity\StockAlertConfig c')->execute(); + // MailTemplateを削除し、フォールバックテンプレート(@StockAlertMail/Mail/...)を使用させる + $mailTemplate = $this->entityManager->getRepository(MailTemplate::class) + ->findOneBy(['file_name' => PluginManager::MAIL_TEMPLATE_FILE_NAME]); + if ($mailTemplate !== null) { + $this->entityManager->remove($mailTemplate); + $this->entityManager->flush(); + } + // DQL DELETE はアイデンティティマップを更新しないため、手動でクリアして古い参照を除去する + $this->entityManager->clear(); // setUp済みのカーネルをそのまま使い、再ブートによるEntityManager無効化を防ぐ $application = new Application(static::$kernel); @@ -59,6 +73,7 @@ protected function setUp(): void $config->setUpdateDate(new \DateTime()); $this->entityManager->persist($config); $this->entityManager->flush(); + $this->configId = $config->getId(); } protected function tearDown(): void @@ -69,11 +84,16 @@ protected function tearDown(): void parent::tearDown(); } - public function testCommandSuccess() + public function testCommandSuccessNoAlert() { + // 閾値を-1にして、どの商品もアラート対象にならないようにする + $config = $this->configRepository->find($this->configId); + $config->setThreshold(-1); + $this->entityManager->flush(); + $this->commandTester->execute([]); - $this->assertSame(0, $this->commandTester->getStatusCode()); + $this->assertSame(0, $this->commandTester->getStatusCode(), $this->commandTester->getDisplay()); } public function testNoDuplicateSend() @@ -94,7 +114,7 @@ public function testMailSentWhenLowStock() $this->commandTester->execute([]); - $this->assertSame(0, $this->commandTester->getStatusCode()); + $this->assertSame(0, $this->commandTester->getStatusCode(), $this->commandTester->getDisplay()); $this->assertEmailCount(1); /** @var Email $message */ @@ -109,7 +129,7 @@ public function testMailSentWhenLowStock() public function testNoMailWhenNoLowStock() { // threshold=-1 に変更(stock は 0 以上なので対象外) - $config = $this->configRepository->findOneBy([]); + $config = $this->configRepository->find($this->configId); $config->setThreshold(-1); $this->entityManager->flush(); @@ -140,7 +160,7 @@ public function testStockRecoveryResetsLog() $this->entityManager->flush(); // threshold=-1 に変更(在庫回復済み扱い) - $config = $this->configRepository->findOneBy([]); + $config = $this->configRepository->find($this->configId); $config->setThreshold(-1); $this->entityManager->flush(); @@ -151,62 +171,66 @@ public function testStockRecoveryResetsLog() $this->assertNull($this->logRepository->findOneBy(['ProductClass' => $productClass])); } - public function testCustomMailSubject() + public function testMailSubjectFormat() { $this->createProduct(); - $config = $this->configRepository->findOneBy([]); - $config->setMailSubject('[{shop_name}] カスタム件名テスト'); - $this->entityManager->flush(); - $this->commandTester->execute([]); - $this->assertSame(0, $this->commandTester->getStatusCode()); + $this->assertSame(0, $this->commandTester->getStatusCode(), $this->commandTester->getDisplay()); $this->assertEmailCount(1); /** @var Email $message */ $message = $this->getMailerMessage(0); - $this->assertStringContainsString('カスタム件名テスト', $message->getSubject()); + // 件名が "[ショップ名] 在庫アラート通知" の形式であること + $this->assertStringContainsString('在庫アラート通知', $message->getSubject()); + $this->assertStringStartsWith('[', $message->getSubject()); } - public function testCustomMailBody() + public function testMailSubjectFallbackWhenEmpty() { $this->createProduct(); - $config = $this->configRepository->findOneBy([]); - $config->setMailBody("カスタム本文テスト\n閾値:{threshold}\n{items}"); + // 空白のみの件名を持つMailTemplateを作成 + $mailTemplate = new MailTemplate(); + $mailTemplate->setName('在庫アラートメール'); + $mailTemplate->setFileName(PluginManager::MAIL_TEMPLATE_FILE_NAME); + $mailTemplate->setMailSubject(' '); + $mailTemplate->setCreateDate(new \DateTime()); + $mailTemplate->setUpdateDate(new \DateTime()); + $this->entityManager->persist($mailTemplate); $this->entityManager->flush(); $this->commandTester->execute([]); - $this->assertSame(0, $this->commandTester->getStatusCode()); + $this->assertSame(0, $this->commandTester->getStatusCode(), $this->commandTester->getDisplay()); $this->assertEmailCount(1); /** @var Email $message */ $message = $this->getMailerMessage(0); - $body = $message->getTextBody(); - $this->assertStringContainsString('カスタム本文テスト', $body); - $this->assertStringContainsString('閾値:9999', $body); - // {items} が展開されて ■ 商品名 が含まれること - $this->assertStringContainsString('■', $body); + // 空白のみの件名はデフォルト「在庫アラート通知」にフォールバックされること + $this->assertStringContainsString('在庫アラート通知', $message->getSubject()); + + // テスト後にMailTemplateを削除 + $this->entityManager->remove($mailTemplate); + $this->entityManager->flush(); } - public function testDefaultTemplateUsedWhenBodyEmpty() + public function testMailBodyFromDefaultTemplate() { $this->createProduct(); - // mailBody を明示的に null のままにする(デフォルトテンプレート使用) - $config = $this->configRepository->findOneBy([]); - $this->assertNull($config->getMailBody()); - $this->commandTester->execute([]); - $this->assertSame(0, $this->commandTester->getStatusCode()); + $this->assertSame(0, $this->commandTester->getStatusCode(), $this->commandTester->getDisplay()); $this->assertEmailCount(1); /** @var Email $message */ $message = $this->getMailerMessage(0); // デフォルトTwigテンプレートの文字列が含まれること - $this->assertStringContainsString('在庫アラート通知', $message->getSubject()); + $body = $message->getTextBody(); + $this->assertNotNull($body, 'Text body should not be null.'); + $this->assertStringContainsString('管理者様', $body); + $this->assertStringContainsString('在庫', $body); } }