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 / Logging / LogRepository.php
mailpoet / lib / Logging Last commit date
RestApi 1 month ago LogHandler.php 10 months ago LogListingRepository.php 1 month ago LogRepository.php 2 weeks ago LoggerFactory.php 10 months ago LogsDownload.php 2 weeks ago PluginVersionProcessor.php 3 years ago index.php 3 years ago
LogRepository.php
320 lines
1 <?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
2
3 namespace MailPoet\Logging;
4
5 if (!defined('ABSPATH')) exit;
6
7
8 use MailPoet\Doctrine\Repository;
9 use MailPoet\Entities\LogEntity;
10 use MailPoet\Entities\NewsletterEntity;
11 use MailPoet\InvalidStateException;
12 use MailPoet\Util\Helpers;
13 use MailPoetVendor\Carbon\Carbon;
14 use MailPoetVendor\Doctrine\DBAL\ArrayParameterType;
15 use MailPoetVendor\Doctrine\DBAL\ParameterType;
16
17 /**
18 * @extends Repository<LogEntity>
19 */
20 class LogRepository extends Repository {
21 public function saveLog(LogEntity $log): void {
22 // Save log entity using DBAL to avoid calling "flush()" on the entity manager.
23 // Calling "flush()" can have unintended side effects, such as saving unwanted
24 // changes or trying to save entities that were detached from the entity manager.
25 $this->entityManager->getConnection()->insert(
26 $this->entityManager->getClassMetadata(LogEntity::class)->getTableName(),
27 [
28 'name' => $log->getName(),
29 'level' => $log->getLevel(),
30 'message' => $log->getMessage(),
31 'raw_message' => $log->getRawMessage(),
32 'context' => json_encode($log->getContext()),
33 'created_at' => (
34 $log->getCreatedAt() ?? Carbon::now()->millisecond(0)
35 )->format('Y-m-d H:i:s'),
36 ],
37 );
38
39 // sync the changes with the entity manager
40 if ($this->entityManager->isOpen()) {
41 $lastInsertId = (int)$this->entityManager->getConnection()->lastInsertId();
42 $log->setId($lastInsertId);
43 $this->entityManager->getUnitOfWork()->registerManaged($log, ['id' => $log->getId()], []);
44 $this->entityManager->refresh($log);
45 }
46 }
47
48 /**
49 * @param \DateTimeInterface|null $dateFrom
50 * @param \DateTimeInterface|null $dateTo
51 * @param string|null $search
52 * @param string $offset
53 * @param string $limit
54 * @return LogEntity[]
55 */
56 public function getLogs(
57 ?\DateTimeInterface $dateFrom = null,
58 ?\DateTimeInterface $dateTo = null,
59 ?string $search = null,
60 ?string $offset = null,
61 ?string $limit = null
62 ): array {
63 $query = $this->doctrineRepository->createQueryBuilder('l')
64 ->select('l');
65
66 if ($dateFrom instanceof \DateTimeInterface) {
67 $query
68 ->andWhere('l.createdAt >= :dateFrom')
69 ->setParameter('dateFrom', $dateFrom->format('Y-m-d 00:00:00'));
70 }
71 if ($dateTo instanceof \DateTimeInterface) {
72 $query
73 ->andWhere('l.createdAt <= :dateTo')
74 ->setParameter('dateTo', $dateTo->format('Y-m-d 23:59:59'));
75 }
76 if ($search) {
77 $search = Helpers::escapeSearch($search);
78 $query
79 ->andWhere('l.name LIKE :search or l.message LIKE :search')
80 ->setParameter('search', "%$search%");
81 }
82
83 $query->orderBy('l.createdAt', 'desc');
84 if ($offset !== null) {
85 $query->setFirstResult((int)$offset);
86 }
87 if ($limit === null) {
88 $query->setMaxResults(500);
89 } else {
90 $query->setMaxResults((int)$limit);
91 }
92
93
94 return $query->getQuery()->getResult();
95 }
96
97 /**
98 * Distinct log names (sources), used to populate the listing's name filter.
99 *
100 * @return string[]
101 */
102 public function getDistinctNames(): array {
103 $rows = $this->entityManager->createQueryBuilder()
104 ->select('DISTINCT l.name')
105 ->from(LogEntity::class, 'l')
106 ->where('l.name IS NOT NULL')
107 ->andWhere("l.name != ''")
108 ->orderBy('l.name', 'asc')
109 ->getQuery()
110 ->getSingleColumnResult();
111
112 $names = [];
113 foreach ($rows as $row) {
114 if (is_string($row)) {
115 $names[] = $row;
116 }
117 }
118 return $names;
119 }
120
121 public function purgeOldLogs(int $daysToKeepLogs, int $limit = 1000): int {
122 $logsTable = $this->entityManager->getClassMetadata(LogEntity::class)->getTableName();
123 $result = $this->entityManager->getConnection()->executeStatement(
124 "
125 DELETE FROM `{$logsTable}`
126 WHERE `created_at` < :date
127 ORDER BY `created_at` ASC, `id` ASC
128 LIMIT :limit
129 ",
130 [
131 'date' => Carbon::now()->subDays($daysToKeepLogs)->toDateTimeString(),
132 'limit' => $limit,
133 ],
134 [
135 'date' => ParameterType::STRING,
136 'limit' => ParameterType::INTEGER,
137 ]
138 );
139
140 return (int)$result;
141 }
142
143 /**
144 * Delete logs matching the listing's filter shape (`from`/`to`/`name`/`level`)
145 * and free-text search, so a deletion removes exactly what the filtered
146 * listing shows. Deletes in batches to keep each statement bounded on large
147 * log tables, mirroring purgeOldLogs().
148 *
149 * @param array{from?: string, to?: string, name?: string[], level?: int[]} $filter
150 */
151 public function deleteLogs(array $filter, ?string $search = null, int $batchSize = 1000): int {
152 $logsTable = $this->entityManager->getClassMetadata(LogEntity::class)->getTableName();
153 [$where, $parameters, $types] = $this->buildFilterSql($filter, $search);
154 $parameters['batch_limit'] = $batchSize;
155 $types['batch_limit'] = ParameterType::INTEGER;
156
157 $sql = "DELETE FROM `{$logsTable}`{$where} ORDER BY `created_at` ASC, `id` ASC LIMIT :batch_limit";
158 $connection = $this->entityManager->getConnection();
159
160 $deleted = 0;
161 do {
162 $affected = (int)$connection->executeStatement($sql, $parameters, $types);
163 $deleted += $affected;
164 } while ($affected === $batchSize);
165
166 return $deleted;
167 }
168
169 /**
170 * Fetch logs matching the listing's filter shape (`from`/`to`/`name`/`level`)
171 * and free-text search for export, so a download contains exactly the rows the
172 * filtered listing shows. Mirrors deleteLogs()'s WHERE clause and is capped by
173 * $limit.
174 *
175 * Yields rows in batches via keyset pagination on (`created_at`, `id`) rather
176 * than one big result set. MailPoet's WPDB-backed Doctrine driver buffers an
177 * entire result in memory (see Doctrine\WPDB\Result), so a single
178 * `LIMIT 50000` query would load every row at once and can exhaust PHP's
179 * memory on large installs. Paging keeps peak memory at one batch.
180 *
181 * @param array{from?: string, to?: string, name?: string[], level?: int[]} $filter
182 * @return iterable<int, array{created_at: string, name: string|null, message: string|null}>
183 */
184 public function getLogsForExport(array $filter, ?string $search = null, int $limit = 50000, int $batchSize = 1000): iterable {
185 $logsTable = $this->entityManager->getClassMetadata(LogEntity::class)->getTableName();
186 [$where, $baseParameters, $baseTypes] = $this->buildFilterSql($filter, $search);
187
188 $batchSize = $batchSize > 0 ? $batchSize : 1000;
189 $remaining = $limit;
190 $lastCreatedAt = null;
191 $lastId = null;
192
193 while ($remaining > 0) {
194 $batch = (int)min($batchSize, $remaining);
195 $parameters = $baseParameters;
196 $types = $baseTypes;
197 $conditions = $where;
198
199 if ($lastCreatedAt !== null) {
200 // Keyset cursor: continue strictly after the last row in the same
201 // (`created_at` DESC, `id` DESC) order. Stable even while new rows are
202 // inserted at the top of the table during the export.
203 $keyset = '(`created_at` < :ks_created_at OR (`created_at` = :ks_created_at AND `id` < :ks_id))';
204 $conditions = $conditions === '' ? ' WHERE ' . $keyset : $conditions . ' AND ' . $keyset;
205 $parameters['ks_created_at'] = $lastCreatedAt;
206 $parameters['ks_id'] = $lastId;
207 $types['ks_created_at'] = ParameterType::STRING;
208 $types['ks_id'] = ParameterType::INTEGER;
209 }
210
211 $parameters['batch_limit'] = $batch;
212 $types['batch_limit'] = ParameterType::INTEGER;
213
214 $sql = "SELECT `id`, `created_at`, `name`, `message` FROM `{$logsTable}`{$conditions} ORDER BY `created_at` DESC, `id` DESC LIMIT :batch_limit";
215
216 $rows = $this->entityManager->getConnection()
217 ->executeQuery($sql, $parameters, $types)
218 ->fetchAllAssociative();
219
220 if ($rows === []) {
221 break;
222 }
223
224 foreach ($rows as $row) {
225 $lastCreatedAt = $this->castToNullableString($row['created_at']);
226 $lastId = (int)$this->castToNullableString($row['id']);
227 yield [
228 'created_at' => $this->castToNullableString($row['created_at']) ?? '',
229 'name' => $this->castToNullableString($row['name']),
230 'message' => $this->castToNullableString($row['message']),
231 ];
232 $remaining--;
233 }
234
235 // A short page means the table is exhausted; stop before an empty query.
236 if (count($rows) < $batch) {
237 break;
238 }
239 }
240 }
241
242 /**
243 * @param mixed $value
244 */
245 private function castToNullableString($value): ?string {
246 return is_scalar($value) ? (string)$value : null;
247 }
248
249 public function getRawMessagesForNewsletter(NewsletterEntity $newsletter, string $topic): array {
250 return $this->entityManager->createQueryBuilder()
251 ->select('DISTINCT logs.rawMessage message')
252 ->from(LogEntity::class, 'logs')
253 ->where('logs.name = :topic')
254 ->andWhere('logs.context LIKE :context')
255 ->orderBy('logs.createdAt')
256 ->setParameter('context', json_encode(['newsletter_id' => $newsletter->getId()]))
257 ->setParameter('topic', $topic)
258 ->getQuery()
259 ->getSingleColumnResult();
260 }
261
262 public function persist($entity): void {
263 throw new InvalidStateException('Use saveLog() instead to avoid unintended side effects');
264 }
265
266 public function flush(): void {
267 throw new InvalidStateException('Use saveLog() instead to avoid unintended side effects');
268 }
269
270 protected function getEntityClassName() {
271 return LogEntity::class;
272 }
273
274 /**
275 * Build the WHERE clause shared by log deletion. Day boundaries
276 * (`00:00:00`–`23:59:59`) and the literal LOCATE() search match
277 * LogListingRepository so deleting honours the same rows the listing shows.
278 *
279 * @param array{from?: string, to?: string, name?: string[], level?: int[]} $filter
280 * @return array{0: string, 1: array<string, mixed>, 2: array<string, int>}
281 */
282 private function buildFilterSql(array $filter, ?string $search): array {
283 $conditions = [];
284 $parameters = [];
285 $types = [];
286
287 if (!empty($filter['from'])) {
288 $conditions[] = '`created_at` >= :date_from';
289 $parameters['date_from'] = $filter['from'] . ' 00:00:00';
290 $types['date_from'] = ParameterType::STRING;
291 }
292 if (!empty($filter['to'])) {
293 $conditions[] = '`created_at` <= :date_to';
294 $parameters['date_to'] = $filter['to'] . ' 23:59:59';
295 $types['date_to'] = ParameterType::STRING;
296 }
297 if (!empty($filter['name'])) {
298 $conditions[] = '`name` IN (:names)';
299 $parameters['names'] = array_values($filter['name']);
300 $types['names'] = ArrayParameterType::STRING;
301 }
302 if (!empty($filter['level'])) {
303 $conditions[] = '`level` IN (:levels)';
304 $parameters['levels'] = array_values($filter['level']);
305 $types['levels'] = ArrayParameterType::INTEGER;
306 }
307 if ($search !== null && trim($search) !== '') {
308 $conditions[] = '(LOCATE(:search, `name`) > 0 OR LOCATE(:search, `message`) > 0)';
309 $parameters['search'] = trim($search);
310 $types['search'] = ParameterType::STRING;
311 }
312
313 return [
314 $conditions === [] ? '' : ' WHERE ' . implode(' AND ', $conditions),
315 $parameters,
316 $types,
317 ];
318 }
319 }
320