PluginProbe ʕ •ᴥ•ʔ
MailPoet – Newsletters, Email Marketing, and Automation / 5.34.2
MailPoet – Newsletters, Email Marketing, and Automation v5.34.2
5.34.2 5.34.1 5.34.0 5.33.1 5.33.0 5.32.0 5.31.0 5.30.0 5.29.0 5.28.1 5.28.0 5.27.0 5.26.0 5.26.1 5.25.0 5.24.0 4.43.0 4.43.1 4.44.0 4.44.1 4.45.0 4.46.0 4.47.0 4.48.0 4.48.1 4.48.2 4.49.0 4.49.1 4.5.0 4.5.1 4.5.2 4.50.0 4.50.1 4.51.0 4.51.1 4.51.2 4.52.0 4.53.0 4.54.0 4.55.0 4.56.0 4.57.0 4.58.0 4.58.1 4.58.2 4.6.0 4.6.1 4.6.2 4.7.0 4.7.1 4.8.0 4.8.1 4.9.0 5.0.0 5.0.1 5.0.2 5.1.0 5.1.1 5.10.0 5.10.1 5.11.0 5.12.0 5.12.1 5.12.10 5.12.11 5.12.12 5.12.13 5.12.2 5.12.3 5.12.4 5.12.5 5.12.6 5.12.7 5.12.8 5.12.9 5.13.0 5.13.1 5.13.2 5.14.0 5.14.1 5.14.2 5.14.3 5.15.0 5.15.1 5.16.0 5.16.1 5.16.2 5.16.3 5.16.4 5.17.0 5.17.1 5.17.2 5.17.3 5.17.4 5.17.5 5.17.6 5.18.0 5.19.0 5.2.0 5.2.1 5.2.2 5.2.3 5.20.0 5.21.0 5.21.1 5.21.2 5.21.3 5.22.0 5.22.1 5.22.2 5.22.3 5.22.4 5.23.0 5.23.1 5.23.2 5.3.0 5.3.1 5.3.2 5.3.3 5.3.4 5.3.5 5.3.6 5.3.7 5.4.0 5.4.1 5.4.2 5.5.0 5.5.1 5.5.2 5.6.0 5.6.1 5.6.2 5.6.3 5.6.4 5.7.0 5.7.1 5.8.0 5.8.1 5.9.0 3.0.0-beta.15 3.7.1 3.0.0-beta.16 3.7.2 3.0.0-beta.17 3.7.3 3.0.0-beta.18 3.7.4 3.0.0-beta.19 3.7.5 3.0.0-beta.2 3.7.6 3.0.0-beta.20 3.7.8 3.0.0-beta.21 3.70.0 3.0.0-beta.22 3.71.0 3.0.0-beta.23 3.71.1 3.0.0-beta.23.1 3.71.2 3.0.0-beta.23.2 3.71.3 3.0.0-beta.24 3.72.0 3.0.0-beta.25 3.73.0 3.0.0-beta.26 3.73.1 3.0.0-beta.27 3.73.2 3.0.0-beta.28 3.74.0 3.0.0-beta.29 3.74.1 3.0.0-beta.3 3.74.2 3.0.0-beta.30 3.74.3 3.0.0-beta.31 3.75.0 3.0.0-beta.32 3.75.1 3.0.0-beta.33 3.76.0 3.0.0-beta.33.1 3.77.0 3.0.0-beta.34.0.0 3.77.1 3.0.0-beta.36.0.0 3.78.0 3.0.0-beta.36.0.1 3.79.0 3.0.0-beta.36.2.0 3.8 3.0.0-beta.36.3.0 3.8.1 3.0.0-beta.36.3.1 3.8.2 3.0.0-beta.37.0.0 3.8.3 3.0.0-beta.4 3.8.4 3.0.0-beta.5 3.8.5 3.0.0-beta.6 3.8.6 3.0.0-beta.7 3.80.0 3.0.0-beta.7.1 3.81.0 3.0.0-beta.8 3.82.0 3.0.0-beta.9 3.83.0 3.0.0-rc.1.0.0 3.84.0 3.0.0-rc.1.0.1 3.84.1 3.0.0-rc.1.0.2 3.85.0 3.0.0-rc.1.0.3 3.85.1 3.0.0-rc.1.0.4 3.86.0 3.0.0-rc.2.0.0 3.87.0 3.0.0-rc.2.0.1 3.87.1 3.0.0-rc.2.0.2 3.87.2 3.0.0-rc.2.0.3 3.88.0 3.0.1 3.88.1 3.0.2 3.88.2 3.0.3 3.89.0 3.0.4 3.89.1 3.0.5 3.89.2 3.0.6 3.89.3 3.0.7 3.89.4 3.0.8 3.9.0 3.0.9 3.9.1 3.1.0 3.90.0 3.10 3.90.1 3.10.1 3.90.2 3.100.0 3.91.0 3.100.1 3.91.1 3.100.2 3.92.0 3.101.0 3.92.1 3.101.1 3.93.0 3.102.0 3.93.1 3.102.1 3.94.0 3.103.0 3.95.0 3.103.1 3.95.1 3.11.0 3.96.0 3.11.1 3.96.1 3.11.2 3.97.0 3.11.3 3.98.0 3.11.4 3.98.1 3.11.5 3.99.0 3.12.0 3.99.1 3.12.1 4.0.0 3.13.0 4.0.1 3.14.0 4.1.0 3.14.1 4.1.1 3.15.0 4.10.0 3.16.0 4.11.0 3.16.1 4.11.1 3.16.2 4.12.0 3.16.3 4.12.1 3.17.0 4.12.2 3.17.1 4.13.0 3.17.2 4.14.0 3.18.0 4.15.0 3.18.1 4.16.0 3.18.2 4.17.0 3.19.0 4.17.1 3.19.1 4.18.0 3.19.2 4.18.1 3.19.3 4.19.0 3.2.0 4.2.0 3.2.1 4.20.0 3.2.2 4.20.1 3.2.3 4.20.2 3.2.4 4.21.0 3.2.5 4.22.0 3.20.0 4.22.1 3.21.0 4.22.2 3.21.1 4.23.0 3.22.0 4.24.0 3.23.0 4.25.0 3.23.1 4.26.0 3.23.2 4.26.1 3.24.0 4.27.0 3.25.0 4.28.0 3.25.1 4.29.0 3.26.0 4.3.0 3.26.1 4.3.1 3.27.0 4.30.0 3.28.0 4.31.0 3.29.0 4.31.1 3.3.0 4.32.0 3.3.1 4.33.0 3.3.2 4.34.0 3.3.3 4.35.0 3.3.4 4.35.1 3.3.5 4.36.0 3.3.6 4.37.0 3.30.0 4.38.0 3.31.0 4.39.0 3.31.1 4.4.0 3.32.0 4.40.0 3.32.1 4.41.0 3.32.2 4.41.1 3.33.0 4.41.2 3.34.0 4.41.3 3.34.1 4.42.0 3.34.2 4.42.1 3.34.3 3.34.4 3.35.0 3.35.1 3.35.3 3.35.4 3.36.0 3.37.0 3.37.1 3.37.2 3.37.3 3.38.0 3.38.1 3.39.0 3.39.1 3.39.2 3.4.0 3.4.1 3.4.2 3.4.3 3.4.4 3.40.0 3.40.1 3.41.0 3.41.1 3.41.2 3.42.0 3.42.1 3.42.2 3.42.3 3.43.0 3.43.1 3.44.0 3.45.0 3.45.1 3.46.0 3.46.1 3.46.10 3.46.11 3.46.12 3.46.13 3.46.14 3.46.2 3.46.3 3.46.4 3.46.5 3.46.6 3.46.7 3.46.8 3.46.9 3.47.0 3.47.1 3.47.10 3.47.11 3.47.2 3.47.3 3.47.5 3.47.6 3.47.7 3.47.9 3.48.0 3.48.1 3.49.0 3.49.1 3.5.0 3.5.1 3.50.0 3.51.0 3.51.1 3.51.2 3.52.0 3.53.0 3.54.0 3.54.1 3.54.2 3.54.3 3.55.0 3.55.1 3.56.0 3.56.1 3.56.2 3.57.0 3.57.1 3.58.0 3.59.0 3.59.1 3.59.2 3.6.0 3.6.1 3.6.2 3.6.3 3.6.4 3.6.5 3.6.6 3.6.7 3.60.0 3.60.1 3.60.10 3.60.11 3.60.12 3.60.2 3.60.3 3.60.4 3.60.6 3.60.7 3.60.8 3.60.9 3.61.0 3.62.0 3.62.1 3.63.0 3.64.0 3.64.1 3.64.2 3.64.3 3.65.0 trunk 3.65.1 3.0.0 3.66.0 3.0.0-beta.1 3.67.0 3.0.0-beta.10 3.67.1 3.0.0-beta.11 3.68.0 3.0.0-beta.12 3.69.0 3.0.0-beta.13 3.69.1 3.0.0-beta.14 3.7.0
mailpoet / lib / Newsletter / Statistics / Export / StatisticsExporter.php
mailpoet / lib / Newsletter / Statistics / Export Last commit date
StatisticsExporter.php 1 week ago index.php 2 months ago
StatisticsExporter.php
285 lines
1 <?php declare(strict_types = 1);
2
3 namespace MailPoet\Newsletter\Statistics\Export;
4
5 if (!defined('ABSPATH')) exit;
6
7
8 use MailPoet\Entities\NewsletterEntity;
9 use MailPoet\Newsletter\Sending\TimeZoneCampaignScheduler;
10 use MailPoet\Newsletter\Statistics\NewsletterStatistics;
11 use MailPoet\Newsletter\Statistics\NewsletterStatisticsRepository;
12 use MailPoet\Newsletter\Statistics\WooCommerceRevenue;
13 use MailPoet\Router\Endpoints\ExportDownload;
14 use MailPoet\WP\Functions as WPFunctions;
15 use MailPoetVendor\XLSXWriter;
16
17 class StatisticsExporter {
18 public const FORMAT_CSV = 'csv';
19 public const FORMAT_XLSX = 'xlsx';
20
21 public const FILE_PREFIX = 'MailPoet_stats_export_';
22 /**
23 * Filter applied to populate per-recipient rows when exporting recipients.
24 * The free plugin ships an empty implementation; the premium plugin
25 * registers a callback that returns the per-recipient data.
26 *
27 * Filter signature: (array $rows, NewsletterEntity $newsletter): array
28 */
29 public const FILTER_RECIPIENT_ROWS = 'mailpoet_statistics_export_recipient_rows';
30
31 /** @var NewsletterStatisticsRepository */
32 private $statisticsRepository;
33
34 /** @var TimeZoneCampaignScheduler */
35 private $timeZoneCampaignScheduler;
36
37 /** @var WPFunctions */
38 private $wp;
39
40 public function __construct(
41 NewsletterStatisticsRepository $statisticsRepository,
42 TimeZoneCampaignScheduler $timeZoneCampaignScheduler,
43 WPFunctions $wp
44 ) {
45 $this->statisticsRepository = $statisticsRepository;
46 $this->timeZoneCampaignScheduler = $timeZoneCampaignScheduler;
47 $this->wp = $wp;
48 }
49
50 /**
51 * Export aggregate stats for a single newsletter to CSV or XLSX.
52 *
53 * @return array{exportFileURL: string, totalExported: int}
54 */
55 public function exportSingleAggregate(NewsletterEntity $newsletter, string $format): array {
56 $format = $this->normalizeFormat($format);
57 $headers = $this->getAggregateHeaders();
58 $stats = $this->statisticsRepository->getStatistics($newsletter);
59 $row = $this->buildAggregateRow($newsletter, $stats);
60
61 $this->ensureExportDirectory();
62 $file = ExportDownload::createExportFile(self::FILE_PREFIX, $format);
63 $this->writeFile($file['path'], $headers, [$row], $format);
64
65 return [
66 'exportFileURL' => $this->getExportFileUrl($file['token'], $format),
67 'totalExported' => 1,
68 ];
69 }
70
71 /**
72 * Export aggregate stats for multiple newsletters to CSV or XLSX (one
73 * row per newsletter). Used by the bulk export action on the listing.
74 *
75 * @param NewsletterEntity[] $newsletters
76 * @return array{exportFileURL: string, totalExported: int}
77 */
78 public function exportBulkAggregate(array $newsletters, string $format): array {
79 $format = $this->normalizeFormat($format);
80 $headers = $this->getAggregateHeaders();
81
82 $rows = [];
83 foreach ($newsletters as $newsletter) {
84 if (!$newsletter instanceof NewsletterEntity) {
85 continue;
86 }
87 $stats = $this->statisticsRepository->getStatistics($newsletter);
88 $rows[] = $this->buildAggregateRow($newsletter, $stats);
89 }
90
91 $this->ensureExportDirectory();
92 $file = ExportDownload::createExportFile(self::FILE_PREFIX, $format);
93 $this->writeFile($file['path'], $headers, $rows, $format);
94
95 return [
96 'exportFileURL' => $this->getExportFileUrl($file['token'], $format),
97 'totalExported' => count($rows),
98 ];
99 }
100
101 /**
102 * Export per-recipient stats for a newsletter to CSV or XLSX. Rows are
103 * populated via the FILTER_RECIPIENT_ROWS filter — the free plugin returns
104 * no rows, premium populates them.
105 *
106 * @return array{exportFileURL: string, totalExported: int}
107 */
108 public function exportRecipients(NewsletterEntity $newsletter, string $format): array {
109 $format = $this->normalizeFormat($format);
110 $headers = $this->getRecipientHeaders($newsletter);
111
112 /** @var array<array<int|string|float|null>> $rows */
113 $rows = (array)$this->wp->applyFilters(self::FILTER_RECIPIENT_ROWS, [], $newsletter);
114 if (count($headers) > count($this->getRecipientHeaders())) {
115 $headerCount = count($headers);
116 foreach ($rows as $index => $row) {
117 $rows[$index] = array_pad($row, $headerCount, '');
118 }
119 }
120
121 $this->ensureExportDirectory();
122 $file = ExportDownload::createExportFile(self::FILE_PREFIX, $format);
123 $this->writeFile($file['path'], $headers, $rows, $format);
124
125 return [
126 'exportFileURL' => $this->getExportFileUrl($file['token'], $format),
127 'totalExported' => count($rows),
128 ];
129 }
130
131 /**
132 * Recipient export headers. For subscriber-timezone campaigns three extra
133 * delivery columns are appended; the premium plugin appends the matching
134 * row cells in `RecipientsExporter::getRows()` using the same resolver, so
135 * the two MUST stay in sync.
136 *
137 * @return string[]
138 */
139 public function getRecipientHeaders(?NewsletterEntity $newsletter = null): array {
140 $headers = [
141 __('Subscriber ID', 'mailpoet'),
142 __('Email', 'mailpoet'),
143 __('First name', 'mailpoet'),
144 __('Last name', 'mailpoet'),
145 __('Status', 'mailpoet'),
146 __('Opened', 'mailpoet'),
147 __('First open at', 'mailpoet'),
148 __('Open count', 'mailpoet'),
149 __('Machine opened', 'mailpoet'),
150 __('Clicked', 'mailpoet'),
151 __('Click count', 'mailpoet'),
152 __('Bounced', 'mailpoet'),
153 __('Unsubscribed', 'mailpoet'),
154 ];
155 if ($newsletter && $this->isTimeZoneCampaign($newsletter)) {
156 $headers[] = __('Delivery timezone', 'mailpoet');
157 $headers[] = __('Timezone fallback used', 'mailpoet');
158 $headers[] = __('Local send time', 'mailpoet');
159 }
160 return $headers;
161 }
162
163 private function isTimeZoneCampaign(NewsletterEntity $newsletter): bool {
164 return $this->timeZoneCampaignScheduler->resolveTimeZoneCampaignQueue($newsletter) !== null;
165 }
166
167 /**
168 * @return string[]
169 */
170 public function getAggregateHeaders(): array {
171 return [
172 __('Newsletter ID', 'mailpoet'),
173 __('Subject', 'mailpoet'),
174 __('Campaign name', 'mailpoet'),
175 __('Sent at', 'mailpoet'),
176 __('Total sent', 'mailpoet'),
177 __('Unique opens', 'mailpoet'),
178 __('Machine opens', 'mailpoet'),
179 __('Unique clicks', 'mailpoet'),
180 __('Bounces', 'mailpoet'),
181 __('Unsubscribes', 'mailpoet'),
182 __('Revenue', 'mailpoet'),
183 __('Currency', 'mailpoet'),
184 __('Orders', 'mailpoet'),
185 ];
186 }
187
188 /**
189 * Build one aggregate row for a newsletter, in the same column order as
190 * `getAggregateHeaders()`. Public so it can be reused by bulk export in phase 3.
191 *
192 * @return array<int|string|float|null>
193 */
194 public function buildAggregateRow(NewsletterEntity $newsletter, NewsletterStatistics $stats): array {
195 $sentAt = $newsletter->getSentAt();
196 $revenue = $stats->getWooCommerceRevenue();
197
198 return [
199 (int)$newsletter->getId(),
200 (string)$newsletter->getSubject(),
201 (string)($newsletter->getCampaignName() ?? ''),
202 $sentAt ? $sentAt->format('Y-m-d H:i:s') : '',
203 $stats->getTotalSentCount(),
204 $stats->getOpenCount(),
205 $stats->getMachineOpenCount(),
206 $stats->getClickCount(),
207 $stats->getBounceCount(),
208 $stats->getUnsubscribeCount(),
209 $revenue instanceof WooCommerceRevenue ? $revenue->getValue() : '',
210 $revenue instanceof WooCommerceRevenue ? $revenue->getCurrency() : '',
211 $revenue instanceof WooCommerceRevenue ? $revenue->getOrdersCount() : '',
212 ];
213 }
214
215 /**
216 * @param string[] $headers
217 * @param array<array<int|string|float|null>> $rows
218 */
219 private function writeFile(string $filePath, array $headers, array $rows, string $format): void {
220 if ($format === self::FORMAT_XLSX) {
221 $this->writeXlsx($filePath, $headers, $rows);
222 return;
223 }
224 $this->writeCsv($filePath, $headers, $rows);
225 }
226
227 /**
228 * @param string[] $headers
229 * @param array<array<int|string|float|null>> $rows
230 */
231 private function writeCsv(string $filePath, array $headers, array $rows): void {
232 $handle = fopen($filePath, 'w');
233 if ($handle === false) {
234 throw new \RuntimeException('Failed opening file for export.');
235 }
236
237 // UTF-8 BOM so Excel auto-detects encoding (matches Subscribers/ImportExport/Export/Export.php)
238 fwrite($handle, chr(0xEF) . chr(0xBB) . chr(0xBF));
239 $this->writeCsvLine($handle, $headers);
240 foreach ($rows as $row) {
241 $this->writeCsvLine($handle, $row);
242 }
243 fclose($handle);
244 }
245
246 /**
247 * @param resource $handle
248 * @param array<int|string|float|null> $row
249 */
250 private function writeCsvLine($handle, array $row): void {
251 // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.file_ops_fputcsv -- Export handles are created under Env::$tempPath, which is MailPoet's WordPress temp directory.
252 fputcsv($handle, array_map('strval', $row), ',', '"', '');
253 }
254
255 /**
256 * @param string[] $headers
257 * @param array<array<int|string|float|null>> $rows
258 */
259 private function writeXlsx(string $filePath, array $headers, array $rows): void {
260 $writer = new XLSXWriter();
261 $sheetName = __('Statistics', 'mailpoet');
262 $writer->writeSheetHeader($sheetName, array_fill_keys($headers, 'string'));
263 foreach ($rows as $row) {
264 $writer->writeSheetRow($sheetName, $row);
265 }
266 $writer->writeToFile($filePath);
267 }
268
269 private function ensureExportDirectory(): void {
270 ExportDownload::ensureExportDirectory($this->wp);
271 }
272
273 private function getExportFileUrl(string $token, string $format): string {
274 return ExportDownload::buildStatisticsExportUrl($token, $format, $this->wp->homeUrl());
275 }
276
277 private function normalizeFormat(string $format): string {
278 $format = strtolower($format);
279 if ($format !== self::FORMAT_CSV && $format !== self::FORMAT_XLSX) {
280 throw new \InvalidArgumentException(sprintf('Unsupported export format "%s".', $format));
281 }
282 return $format;
283 }
284 }
285