'smtp.test.local', 'smtp_port' => 587, 'smtp_user' => 'user', 'smtp_password' => 'pass', 'smtp_from_email' => 'editor@example.org', 'smtp_from_name' => 'Editor', 'smtp_require_tls' => false, 'imap_host' => 'imap.test.local', 'imap_port' => 993, 'imap_user' => 'user', 'imap_password' => 'pass', 'imap_sent_folder' => 'Sent', 'imap_mailbox_flags' => '/imap/ssl', ]; } } final class FakeSmtp implements SmtpSender { public int $count = 0; /** @var list */ public array $lastAttachments = []; public function send(array $credentials, array $to, array $cc, array $bcc, string $subject, string $htmlBody, ?string $replyTo = null, array $attachments = []): array { $this->count++; $this->lastAttachments = $attachments; if ($to === []) { throw new RuntimeException('No recipients'); } return ['raw_mime' => "To: " . implode(',', $to) . "\r\nSubject: {$subject}\r\n\r\n{$htmlBody}"]; } } final class FakeImap implements ImapAppender { public int $count = 0; public function appendSent(array $credentials, string $rawMime, string $attemptId): void { $this->count++; } } Env::load(dirname(__DIR__, 2) . '/credentials/.env'); $dbConfig = [ 'MYSQL_HOST' => '127.0.0.1', 'MYSQL_PORT' => (string) (getenv('MYSQL_TUNNEL_LOCAL_PORT') ?: '13306'), 'MYSQL_USER' => Env::require('REMOTE_MYSQL_USER'), 'MYSQL_PASSWORD' => Env::require('REMOTE_MYSQL_PASSWORD'), 'MAILSHOTS_REMOTE_MYSQL_DB' => Env::require('MAILSHOTS_REMOTE_MYSQL_DB'), 'MEMBERS_REMOTE_MYSQL_DB' => Env::require('MEMBERS_REMOTE_MYSQL_DB'), ]; $router = new PdoDatabaseRouter($dbConfig); $metadata = new DatabaseSourceMetadataProvider($router); $parser = new DslParser(); $validator = new DslValidator($metadata); $compiler = new DslCompiler($metadata); $queryRepo = new MailshotQueryRepository($router); $mailshotRepo = new MailshotRepository($router); $attachmentRepo = new AttachmentRepository($router); $dataSourceService = new DataSourceService($queryRepo, $router, $parser, $validator, $compiler, $metadata, $mailshotRepo); $lastRunRepo = new LastRunRepository($router); $smtp = new FakeSmtp(); $imap = new FakeImap(); $run = new MailshotRunService( $mailshotRepo, $queryRepo, $attachmentRepo, $dataSourceService, new TemplateRenderer(), $smtp, $imap, new FakeCreds(), $lastRunRepo ); $membersDb = $router->membersDbName(); $stmt = $router->membersPdo()->prepare('SELECT table_name FROM information_schema.tables WHERE table_schema = :schema ORDER BY table_name ASC LIMIT 1'); $stmt->execute(['schema' => $membersDb]); $table = $stmt->fetchColumn(); if (!is_string($table) || $table === '') { fwrite(STDERR, "No source table in members db\n"); exit(1); } $dsl = $membersDb . '.' . $table; $uniq = gmdate('Ymd_His') . '_' . bin2hex(random_bytes(3)); $queryName = 'phase4_ds_' . $uniq; $queryId = null; $mailshotId = null; $attachmentId = null; $attachmentName = 'phase4_att_' . $uniq; try { $savedQ = $dataSourceService->save(null, $queryName, $dsl); if (($savedQ['ok'] ?? false) !== true) { fwrite(STDERR, 'Failed saving datasource: ' . json_encode($savedQ) . "\n"); exit(1); } $queryId = (int) $savedQ['id']; $attachmentId = $attachmentRepo->create([ 'name' => $attachmentName, 'file_name' => 'phase4-note.txt', 'mime_type' => 'text/plain', 'file_bytes' => 'phase4 attachment bytes', ]); $savedM = $mailshotRepo->create([ 'Purpose' => 'Phase4 ' . $uniq, 'DataSource' => $queryName, 'CC' => '', 'BCC' => '', 'Subject' => 'Hello {{ id|default(ID|default("recipient")) }}', 'Message' => '

Hi {{ name|default(Name|default("there")) }}

', 'PDFAttachment' => '

PDF {{ id|default("none") }}

', 'AttachmentNames' => json_encode([$attachmentName], JSON_UNESCAPED_SLASHES), 'PDFFilenameDerivedFrom' => '', 'ReplyTo' => '', ]); $mailshotId = (int) $savedM; $render = $run->renderTest($mailshotId, 0); if (($render['ok'] ?? false) !== true) { fwrite(STDERR, 'renderTest failed: ' . json_encode($render) . "\n"); exit(1); } $pdfBatch = $run->generatePdfBatch($mailshotId); if (($pdfBatch['ok'] ?? false) !== true) { fwrite(STDERR, 'generatePdfBatch failed: ' . json_encode($pdfBatch) . "\n"); exit(1); } if ((int) ($pdfBatch['generated_count'] ?? 0) <= 0) { fwrite(STDERR, "generatePdfBatch should generate at least one PDF\n"); exit(1); } if (strpos((string) ($pdfBatch['merged_pdf_bytes'] ?? ''), '%PDF') !== 0) { fwrite(STDERR, "generatePdfBatch merged_pdf_bytes should be a PDF payload\n"); exit(1); } $sendBlank = $run->sendTest($mailshotId, 0, ''); if (($sendBlank['ok'] ?? true) !== false) { fwrite(STDERR, "sendTest blank email should fail\n"); exit(1); } $sendTest = $run->sendTest($mailshotId, 0, 'receiver@example.org'); if (($sendTest['ok'] ?? false) !== true) { fwrite(STDERR, 'sendTest failed: ' . json_encode($sendTest) . "\n"); exit(1); } if (array_key_exists('rendered', $sendTest)) { fwrite(STDERR, "sendTest should not return rendered payload\n"); exit(1); } if (count($smtp->lastAttachments) < 2) { fwrite(STDERR, "sendTest should include static attachment and rendered PDF attachment\n"); exit(1); } $mimeTypes = array_map(static fn(array $a): string => (string) ($a['mime_type'] ?? ''), $smtp->lastAttachments); if (!in_array('application/pdf', $mimeTypes, true)) { fwrite(STDERR, "sendTest should include a rendered application/pdf attachment\n"); exit(1); } if (!in_array('text/plain', $mimeTypes, true)) { fwrite(STDERR, "sendTest should include configured text/plain attachment\n"); exit(1); } $pdfAttachment = null; foreach ($smtp->lastAttachments as $att) { if ((string) ($att['mime_type'] ?? '') === 'application/pdf') { $pdfAttachment = $att; break; } } if (!is_array($pdfAttachment) || strpos((string) ($pdfAttachment['content_bytes'] ?? ''), '%PDF') !== 0) { fwrite(STDERR, "sendTest PDF attachment content is not a PDF payload\n"); exit(1); } // Regression: configured-but-missing attachment must return a controlled error, not throw/fatal. $mailshotRepo->update($mailshotId, [ 'Purpose' => 'Phase4 ' . $uniq, 'DataSource' => $queryName, 'CC' => '', 'BCC' => '', 'Subject' => 'Hello {{ id|default(ID|default("recipient")) }}', 'Message' => '

Hi {{ name|default(Name|default("there")) }}

', 'PDFAttachment' => '

PDF {{ id|default("none") }}

', 'AttachmentNames' => json_encode(['missing_attachment_name'], JSON_UNESCAPED_SLASHES), 'PDFFilenameDerivedFrom' => '', 'ReplyTo' => '', 'RecipientEmailField' => '', ]); $sendMissing = $run->sendTest($mailshotId, 0, 'receiver@example.org'); if (($sendMissing['ok'] ?? true) !== false) { fwrite(STDERR, "sendTest should fail when configured attachment is missing\n"); exit(1); } $missingText = implode('; ', (array) ($sendMissing['errors'] ?? [])); if (strpos($missingText, 'Attachment "missing_attachment_name" not found.') === false) { fwrite(STDERR, "sendTest missing-attachment error text mismatch: {$missingText}\n"); exit(1); } $runRes = $run->runMailshot($mailshotId); if (($runRes['ok'] ?? false) !== true) { fwrite(STDERR, 'runMailshot failed: ' . json_encode($runRes) . "\n"); exit(1); } if ((int) ($runRes['attempted'] ?? 0) <= 0) { fwrite(STDERR, "runMailshot attempted should be > 0\n"); exit(1); } $rows = $lastRunRepo->listForMailshot($mailshotId); if (count($rows) !== (int) $runRes['attempted']) { fwrite(STDERR, 'lastRun row count mismatch\n'); exit(1); } $retry = $run->retryFailed($mailshotId); if (($retry['ok'] ?? false) !== true) { fwrite(STDERR, 'retryFailed failed: ' . json_encode($retry) . "\n"); exit(1); } echo "Phase 4 run-flow integration test passed\n"; echo "attempted=" . (int) $runRes['attempted'] . "\n"; echo "smtp_calls={$smtp->count}\n"; echo "imap_calls={$imap->count}\n"; } finally { if ($mailshotId !== null) { try { $lastRunRepo->clearForMailshot($mailshotId); } catch (Throwable $e) {} try { $mailshotRepo->delete($mailshotId); } catch (Throwable $e) {} } if ($attachmentId !== null) { try { $attachmentRepo->delete($attachmentId); } catch (Throwable $e) {} } if ($queryId !== null) { try { $queryRepo->delete($queryId); } catch (Throwable $e) {} } }