Platal::kill() doesn't exist, use Platal::page()->kill() instead.
[platal.git] / include / newsletter.inc.php
1 <?php
2 /***************************************************************************
3 * Copyright (C) 2003-2011 Polytechnique.org *
4 * http://opensource.polytechnique.org/ *
5 * *
6 * This program is free software; you can redistribute it and/or modify *
7 * it under the terms of the GNU General Public License as published by *
8 * the Free Software Foundation; either version 2 of the License, or *
9 * (at your option) any later version. *
10 * *
11 * This program is distributed in the hope that it will be useful, *
12 * but WITHOUT ANY WARRANTY; without even the implied warranty of *
13 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
14 * GNU General Public License for more details. *
15 * *
16 * You should have received a copy of the GNU General Public License *
17 * along with this program; if not, write to the Free Software *
18 * Foundation, Inc., *
19 * 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA *
20 ***************************************************************************/
21
22 // {{{ class MailNotFound
23
24 class MailNotFound extends Exception {
25 }
26
27 // }}}
28
29 // {{{ class NewsLetter
30
31 class NewsLetter
32 {
33 public $id; // ID of the NL (in table newsletters)
34 public $group; // Short name of the group corresponding to the NL
35 public $group_id; // ID of that group
36 public $name; // Name of the NL (e.g "Lettre de Polytechnique.org", ...)
37 public $cats; // List of all categories for this NL
38 public $criteria; // PlFlagSet of allowed filters for recipient selection
39
40 protected $custom_css = false;
41
42 // Base name to use instead of the group short name for NLs without a custom CSS
43 const FORMAT_DEFAULT_GROUP = 'default';
44
45 // Diminutif of X.net groups with a specific NL view
46 const GROUP_XORG = 'Polytechnique.org';
47 const GROUP_AX = 'AX';
48 const GROUP_EP = 'Ecole';
49
50 // Searches on mutiple fields
51 const SEARCH_ALL = 'all';
52 const SEARCH_TITLE = 'title';
53
54
55 // {{{ Constructor, NewsLetter retrieval (forGroup, getAll)
56
57 public function __construct($id)
58 {
59 // Load NL data
60 $res = XDB::query('SELECT nls.group_id, g.diminutif AS group_name,
61 nls.name AS nl_name, nls.custom_css, nls.criteria
62 FROM newsletters AS nls
63 LEFT JOIN groups AS g ON (nls.group_id = g.id)
64 WHERE nls.id = {?}',
65 $id);
66 if (!$res->numRows()) {
67 throw new MailNotFound();
68 }
69
70 $data = $res->fetchOneAssoc();
71 $this->id = $id;
72 $this->group_id = $data['group_id'];
73 $this->group = $data['group_name'];
74 $this->name = $data['nl_name'];
75 $this->custom_css = $data['custom_css'];
76 $this->criteria = new PlFlagSet($data['criteria']);
77
78 // Load the categories
79 $res = XDB::iterRow(
80 'SELECT cid, title
81 FROM newsletter_cat
82 WHERE nlid = {?}
83 ORDER BY pos', $id);
84 while (list($cid, $title) = $res->next()) {
85 $this->cats[$cid] = $title;
86 }
87 }
88
89 /** Retrieve the NL associated with a given group.
90 * @p $group Short name of the group
91 * @return A NewsLetter object, or null if the group doesn't have a NL.
92 */
93 public static function forGroup($group)
94 {
95 $res = XDB::query('SELECT nls.id
96 FROM newsletters AS nls
97 LEFT JOIN groups AS g ON (nls.group_id = g.id)
98 WHERE g.diminutif = {?}', $group);
99 if (!$res->numRows()) {
100 return null;
101 }
102 return new NewsLetter($res->fetchOneCell());
103 }
104
105 /** Retrieve all newsletters
106 * @return An array of $id => NewsLetter objects
107 */
108 public static function getAll()
109 {
110 $res = XDB::query('SELECT id
111 FROM newsletters');
112 $nls = array();
113 foreach ($res->fetchColumn() as $id) {
114 $nls[$id] = new NewsLetter($id);
115 }
116 return $nls;
117 }
118
119 // }}}
120 // {{{ Issue retrieval
121
122 /** Retrieve all issues which should be sent
123 * @return An array of NLIssue objects to send (i.e state = 'new' and send_before <= today)
124 */
125 public static function getIssuesToSend()
126 {
127 $res = XDB::query('SELECT id
128 FROM newsletter_issues
129 WHERE state = \'pending\' AND send_before <= NOW()');
130 $issues = array();
131 foreach ($res->fetchColumn() as $id) {
132 $issues[$id] = new NLIssue($id);
133 }
134 return $issues;
135 }
136
137 /** Retrieve a given issue of this NewsLetter
138 * @p $name Name or ID of the issue to retrieve.
139 * @return A NLIssue object.
140 *
141 * $name may be either a short_name, an ID or the special value 'last' which
142 * selects the latest sent NL.
143 * If $name is null, this will retrieve the current pending NL.
144 */
145 public function getIssue($name = null, $only_sent = true)
146 {
147 if ($name) {
148 if ($name == 'last') {
149 if ($only_sent) {
150 $where = 'state = \'sent\' AND ';
151 } else {
152 $where = '';
153 }
154 $res = XDB::query('SELECT MAX(id)
155 FROM newsletter_issues
156 WHERE ' . $where . ' nlid = {?}',
157 $this->id);
158 } else {
159 $res = XDB::query('SELECT id
160 FROM newsletter_issues
161 WHERE nlid = {?} AND (id = {?} OR short_name = {?})',
162 $this->id, $name, $name);
163 }
164 if (!$res->numRows()) {
165 throw new MailNotFound();
166 }
167 $id = $res->fetchOneCell();
168 } else {
169 $query = XDB::format('SELECT id
170 FROM newsletter_issues
171 WHERE nlid = {?} AND state = \'new\'
172 ORDER BY id DESC', $this->id);
173 $res = XDB::query($query);
174 if ($res->numRows()) {
175 $id = $res->fetchOneCell();
176 } else {
177 // Create a new, empty issue, and return it
178 $id = $this->createPending();
179 }
180 }
181
182 return new NLIssue($id, $this);
183 }
184
185 /** Create a new, empty, pending newsletter issue
186 * @p $nlid The id of the NL for which a new pending issue should be created.
187 * @return Id of the newly created issue.
188 */
189 public function createPending()
190 {
191 XDB::execute('INSERT INTO newsletter_issues
192 SET nlid = {?}, state=\'new\', date=NOW(),
193 title=\'to be continued\',
194 mail_title=\'to be continued\'',
195 $this->id);
196 return XDB::insertId();
197 }
198
199 /** Return all sent issues of this newsletter.
200 * @return An array of (id => NLIssue)
201 */
202 public function listSentIssues($check_user = false, $user = null)
203 {
204 if ($check_user && $user == null) {
205 $user = S::user();
206 }
207
208 $res = XDB::query('SELECT id
209 FROM newsletter_issues
210 WHERE nlid = {?} AND state = \'sent\'
211 ORDER BY date DESC', $this->id);
212 $issues = array();
213 foreach ($res->fetchColumn() as $id) {
214 $issue = new NLIssue($id, $this, false);
215 if (!$check_user || $issue->checkUser($user)) {
216 $issues[$id] = $issue;
217 }
218 }
219 return $issues;
220 }
221
222 /** Return all issues of this newsletter, including invalid and sent.
223 * @return An array of (id => NLIssue)
224 */
225 public function listAllIssues()
226 {
227 $res = XDB::query('SELECT id
228 FROM newsletter_issues
229 WHERE nlid = {?}
230 ORDER BY FIELD(state, \'pending\', \'new\') DESC, date DESC', $this->id);
231 $issues = array();
232 foreach ($res->fetchColumn() as $id) {
233 $issues[$id] = new NLIssue($id, $this, false);
234 }
235 return $issues;
236 }
237
238 /** Return the latest pending issue of the newsletter.
239 * @p $create Whether to create an empty issue if no pending issue exist.
240 * @return Either null, or a NL object.
241 */
242 public function getPendingIssue($create = false)
243 {
244 $res = XDB::query('SELECT MAX(id)
245 FROM newsletter_issues
246 WHERE nlid = {?} AND state = \'new\'',
247 $this->id);
248 $id = $res->fetchOneCell();
249 if ($id != null) {
250 return new NLIssue($id, $this);
251 } else if ($create) {
252 $id = $this->createPending();
253 return new NLIssue($id, $this);
254 } else {
255 return null;
256 }
257 }
258
259 /** Returns a list of either issues or articles corresponding to the search.
260 * @p $search The searched pattern.
261 * @p $field The fields where to search, if none given, search in all possible fields.
262 * @return The list of object found.
263 */
264 public function issueSearch($search, $field, $user)
265 {
266 $search = XDB::formatWildcards(XDB::WILDCARD_CONTAINS, $search);
267 if ($field == self::SEARCH_ALL) {
268 $where = '(title ' . $search . ' OR mail_title ' . $search . ' OR head ' . $search . ' OR signature ' . $search . ')';
269 } elseif ($field == self::SEARCH_TITLE) {
270 $where = '(title ' . $search . ' OR mail_title ' . $search . ')';
271 } else {
272 $where = $field . $search;
273 }
274 $list = XDB::fetchColumn('SELECT DISTINCT(id)
275 FROM newsletter_issues
276 WHERE nlid = {?} AND state = \'sent\' AND ' . $where . '
277 ORDER BY date DESC',
278 $this->id);
279
280 $issues = array();
281 foreach ($list as $id) {
282 $issue = new NLIssue($id, $this, false);
283 if ($issue->checkUser($user)) {
284 $issues[] = $issue;
285 }
286 }
287 return $issues;
288 }
289
290 public function articleSearch($search, $field, $user)
291 {
292 $search = XDB::formatWildcards(XDB::WILDCARD_CONTAINS, $search);
293 if ($field == self::SEARCH_ALL) {
294 $where = '(a.title ' . $search . ' OR a.body ' . $search . ' OR a.append ' . $search . ')';
295 } else {
296 $where = 'a.' . $field . $search;
297 }
298 $list = XDB::fetchAllAssoc('SELECT i.short_name, a.aid, i.id, a.title
299 FROM newsletter_art AS a
300 INNER JOIN newsletter_issues AS i ON (a.id = i.id)
301 WHERE i.nlid = {?} AND i.state = \'sent\' AND ' . $where . '
302 GROUP BY a.id, a.aid
303 ORDER BY i.date DESC, a.aid',
304 $this->id);
305
306 $articles = array();
307 foreach ($list as $item) {
308 $issue = new NLIssue($item['id'], $this, false);
309 if ($issue->checkUser($user)) {
310 $articles[] = $item;
311 }
312 }
313 return $articles;
314 }
315
316 // }}}
317 // {{{ Subscription related function
318
319 /** Unsubscribe a user from this newsletter
320 * @p $uid UID to unsubscribe from the newsletter; if null, use current user.
321 * @p $hash True if the uid is actually a hash.
322 * @return True if the user was successfully unsubscribed.
323 */
324 public function unsubscribe($issue_id = null, $uid = null, $hash = false)
325 {
326 if (is_null($uid) && $hash) {
327 // Unable to unsubscribe from an empty hash
328 return false;
329 }
330 $user = is_null($uid) ? S::user()->id() : $uid;
331 $field = $hash ? 'hash' : 'uid';
332 $res = XDB::query('SELECT uid
333 FROM newsletter_ins
334 WHERE nlid = {?} AND ' . $field . ' = {?}',
335 $this->id, $user);
336 if (!$res->numRows()) {
337 // No subscribed user with that UID/hash
338 return false;
339 }
340 $user = $res->fetchOneCell();
341
342 XDB::execute('DELETE FROM newsletter_ins
343 WHERE nlid = {?} AND uid = {?}',
344 $this->id, $user);
345 if (!is_null($issue_id)) {
346 XDB::execute('UPDATE newsletter_issues
347 SET unsubscribe = unsubscribe + 1
348 WHERE id = {?}',
349 $id);
350 }
351 return true;
352 }
353
354 /** Subscribe a user to a newsletter
355 * @p $user User to subscribe to the newsletter; if null, use current user.
356 */
357 public function subscribe($user = null)
358 {
359 if (is_null($user)) {
360 $user = S::user();
361 }
362 if (self::maySubscribe($user)) {
363 XDB::execute('INSERT IGNORE INTO newsletter_ins (nlid, uid, last, hash)
364 VALUES ({?}, {?}, NULL, hash)',
365 $this->id, $user->id());
366 }
367 }
368
369 /** Subscribe a batch of users to a newsletter.
370 * This skips 'maySubscribe' test.
371 *
372 * @p $user_ids Array of user IDs to subscribe to the newsletter.
373 */
374 public function bulkSubscribe($user_ids)
375 {
376 // TODO: use a 'bulkMaySubscribe'.
377 XDB::execute('INSERT IGNORE INTO newsletter_ins (nlid, uid, last, hash)
378 SELECT {?}, a.uid, NULL, NULL
379 FROM accounts AS a
380 WHERE a.uid IN {?}',
381 $this->id, $user_ids);
382 }
383
384 /** Retrieve subscription state of a user
385 * @p $user Target user; if null, use current user.
386 * @return Boolean: true if the user has subscribed to the NL.
387 */
388 public function subscriptionState($user = null)
389 {
390 if (is_null($user)) {
391 $user = S::user();
392 }
393 $res = XDB::query('SELECT 1
394 FROM newsletter_ins
395 WHERE nlid = {?} AND uid = {?}',
396 $this->id, $user->id());
397 return ($res->numRows() == 1);
398 }
399
400 /** Get the count of subscribers to the NL.
401 * @return Number of subscribers.
402 */
403 public function subscriberCount()
404 {
405 return XDB::fetchOneCell('SELECT COUNT(uid)
406 FROM newsletter_ins
407 WHERE nlid = {?}', $this->id);
408 }
409
410 /** Get the count of subscribers with non valid redirection.
411 */
412 public function lostSubscriberCount()
413 {
414 return XDB::fetchOneCell('SELECT COUNT(DISTINCT(n.uid))
415 FROM newsletter_ins AS n
416 INNER JOIN accounts AS a ON (n.uid = a.uid)
417 INNER JOIN account_types AS t ON (t.type = a.type)
418 LEFT JOIN email_redirect_account AS r ON (r.uid = a.uid AND r.flags = \'active\' AND r.broken_level < 3
419 AND r.type != \'imap\' AND r.type != \'homonym\')
420 WHERE n.nlid = {?} AND r.redirect IS NULL AND a.state = \'active\' AND FIND_IN_SET(\'mail\', t.perms)',
421 $this->id);
422 }
423
424 /** Get the number of subscribers to the NL whose last received mailing was $last.
425 * @p $last ID of the issue for which subscribers should be counted.
426 * @return Number of subscribers
427 */
428 public function subscriberCountForLast($last)
429 {
430 return XDB::fetchOneCell('SELECT COUNT(uid)
431 FROM newsletter_ins
432 WHERE nlid = {?} AND last = {?}', $this->id, $last);
433 }
434
435 /** Retrieve the list of newsletters a user has subscribed to
436 * @p $user User whose subscriptions should be retrieved (if null, use session user).
437 * @return Array of newsletter IDs
438 */
439 public static function getUserSubscriptions($user = null)
440 {
441 if (is_null($user)) {
442 $user = S::user();
443 }
444 $res = XDB::query('SELECT nlid
445 FROM newsletter_ins
446 WHERE uid = {?}',
447 $user->id());
448 return $res->fetchColumn();
449 }
450
451 /** Retrieve the UserFilterBuilder for subscribers to this NL.
452 * This is the place where NL-specific filters may be allowed or prevented.
453 * @p $envprefix Prefix to use for env fields (cf. UserFilterBuilder)
454 * @return A UserFilterBuilder object using the given env prefix
455 */
456 public function getSubscribersUFB($envprefix = '')
457 {
458 require_once 'ufbuilder.inc.php';
459 return new UFB_NewsLetter($this->criteria, $envprefix);
460 }
461
462 // }}}
463 // {{{ Permissions related functions
464
465 /** For later use: check whether a given user may subscribe to this newsletter.
466 * @p $user User whose access should be checked
467 * @return Boolean: whether the user may subscribe to the NL.
468 */
469 public function maySubscribe($user = null)
470 {
471 return true;
472 }
473
474 /** Whether a given user may edit this newsletter
475 * @p $uid UID of the user whose perms should be checked (if null, use current user)
476 * @return Boolean: whether the user may edit the NL
477 */
478 public function mayEdit($user = null)
479 {
480 if (is_null($user)) {
481 $user = S::user();
482 }
483 if ($user->checkPerms('admin')) {
484 return true;
485 }
486 $res = XDB::query('SELECT perms
487 FROM group_members
488 WHERE asso_id = {?} AND uid = {?}',
489 $this->group_id, $user->id());
490 return ($res->numRows() && $res->fetchOneCell() == 'admin');
491 }
492
493 /** Whether a given user may submit articles to this newsletter using X.org validation system
494 * @p $user User whose access should be checked (if null, use current user)
495 * @return Boolean: whether the user may submit articles
496 */
497 public function maySubmit($user = null)
498 {
499 // Submission of new articles is only enabled for the X.org NL (and forbidden when viewing issues on X.net)
500 return ($this->group == self::GROUP_XORG && !isset($GLOBALS['IS_XNET_SITE']));
501 }
502
503 // }}}
504 // {{{ Display-related functions: cssFile, tplFile, prefix, admin_prefix, admin_links_enabled, automatic_mailings_enabled
505
506 /** Get the name of the css file used to display this newsletter.
507 */
508 public function cssFile()
509 {
510 if ($this->custom_css) {
511 $base = $this->group;
512 } else {
513 $base = self::FORMAT_DEFAULT_GROUP;
514 }
515 return 'nl.' . $base . '.css';
516 }
517
518 /** Get the name of the template file used to display this newsletter.
519 */
520 public function tplFile()
521 {
522 if ($this->custom_css) {
523 $base = $this->group;
524 } else {
525 $base = self::FORMAT_DEFAULT_GROUP;
526 }
527 return 'newsletter/nl.' . $base . '.mail.tpl';
528 }
529
530 /** Get the prefix leading to the page for this NL
531 * Only X.org / AX / X groups may be seen on X.org.
532 */
533 public function prefix($enforce_xnet=true, $with_group=true)
534 {
535 if (!empty($GLOBALS['IS_XNET_SITE'])) {
536 if ($with_group) {
537 return $this->group . '/nl';
538 } else {
539 return 'nl';
540 }
541 }
542 switch ($this->group) {
543 case self::GROUP_XORG:
544 return 'nl';
545 case self::GROUP_AX:
546 return 'ax';
547 case self::GROUP_EP:
548 return 'epletter';
549 default:
550 // Don't display groups NLs on X.org
551 assert(!$enforce_xnet);
552 }
553 }
554
555 /** Get the prefix to use for all 'admin' pages of this NL.
556 */
557 public function adminPrefix($enforce_xnet=true, $with_group=true)
558 {
559 if (!empty($GLOBALS['IS_XNET_SITE'])) {
560 if ($with_group) {
561 return $this->group . '/admin/nl';
562 } else {
563 return 'admin/nl';
564 }
565 }
566 switch ($this->group) {
567 case self::GROUP_XORG:
568 return 'admin/newsletter';
569 case self::GROUP_AX:
570 return 'ax/admin';
571 case self::GROUP_EP:
572 return 'epletter/admin';
573 default:
574 // Don't display groups NLs on X.org
575 assert(!$enforce_xnet);
576 }
577 }
578
579 /** Hack used to remove "admin" links on X.org page on X.net
580 * The 'admin' links are enabled for all pages, except for X.org when accessing NL through X.net
581 */
582 public function adminLinksEnabled()
583 {
584 return ($this->group != self::GROUP_XORG || !isset($GLOBALS['IS_XNET_SITE']));
585 }
586
587 /** Automatic mailings are disabled for X.org NL.
588 */
589 public function automaticMailingEnabled()
590 {
591 return $this->group != self::GROUP_XORG;
592 }
593
594 public function hasCustomCss()
595 {
596 return $this->custom_css;
597 }
598
599 public function canSyncWithGroup()
600 {
601 switch ($this->group) {
602 case self::GROUP_XORG:
603 case self::GROUP_AX:
604 case self::GROUP_EP:
605 return false;
606 default:
607 return true;
608 }
609 }
610
611 // }}}
612 }
613
614 // }}}
615
616 // {{{ class NLIssue
617
618 // A NLIssue is an issue of a given NewsLetter
619 class NLIssue
620 {
621 protected $nlid; // Id of the newsletter
622
623 const STATE_NEW = 'new'; // New, currently being edited
624 const STATE_PENDING = 'pending'; // Ready for mailing
625 const STATE_SENT = 'sent'; // Sent
626
627 public $nl; // Related NL
628
629 public $id; // Id of this issue of the newsletter
630 public $shortname; // Shortname for this issue
631 public $title; // Title of this issue
632 public $title_mail; // Title of the email
633 public $state; // State of the issue (one of the STATE_ values)
634 public $sufb; // Environment to use to generate the UFC through an UserFilterBuilder
635
636 public $date; // Date at which this issue was sent
637 public $send_before; // Date at which issue should be sent
638 public $head; // Foreword of the issue (or body for letters with no articles)
639 public $signature; // Signature of the letter
640 public $reply_to; // Adress to reply to the message (can be empty)
641 public $arts = array(); // Articles of the issue
642
643 const BATCH_SIZE = 60; // Number of emails to send every minute.
644
645 // {{{ Constructor, id-related functions
646
647 /** Build a NewsLetter.
648 * @p $id: ID of the issue (unique among all newsletters)
649 * @p $nl: Optional argument containing an already built NewsLetter object.
650 */
651 function __construct($id, $nl = null, $fetch_articles = true)
652 {
653 return $this->fetch($id, $nl, $fetch_articles);
654 }
655
656 protected function refresh()
657 {
658 return $this->fetch($this->id, $this->nl, false);
659 }
660
661 protected function fetch($id, $nl = null, $fetch_articles = true)
662 {
663 // Load this issue
664 $res = XDB::query('SELECT nlid, short_name, date, send_before, state, sufb_json,
665 title, mail_title, head, signature, reply_to
666 FROM newsletter_issues
667 WHERE id = {?}',
668 $id);
669 if (!$res->numRows()) {
670 throw new MailNotFound();
671 }
672 $issue = $res->fetchOneAssoc();
673 if ($nl && $nl->id == $issue['nlid']) {
674 $this->nl = $nl;
675 } else {
676 $this->nl = new NewsLetter($issue['nlid']);
677 }
678 $this->id = $id;
679 $this->nlid = $issue['nlid'];
680 $this->shortname = $issue['short_name'];
681 $this->date = $issue['date'];
682 $this->send_before = $issue['send_before'];
683 $this->state = $issue['state'];
684 $this->title = $issue['title'];
685 $this->title_mail = $issue['mail_title'];
686 $this->head = $issue['head'];
687 $this->signature = $issue['signature'];
688 $this->reply_to = $issue['reply_to'];
689 $this->sufb = $this->importJSonStoredUFB($issue['sufb_json']);
690
691 if ($fetch_articles) {
692 $this->fetchArticles();
693 }
694 }
695
696 protected function fetchArticles($force = false)
697 {
698 if (count($this->arts) && !$force) {
699 return;
700 }
701
702 // Load the articles
703 $res = XDB::iterRow(
704 'SELECT a.title, a.body, a.append, a.aid, a.cid, a.pos
705 FROM newsletter_art AS a
706 INNER JOIN newsletter_issues AS ni USING(id)
707 LEFT JOIN newsletter_cat AS c ON (a.cid = c.cid)
708 WHERE a.id = {?}
709 ORDER BY c.pos, a.pos',
710 $this->id);
711 while (list($title, $body, $append, $aid, $cid, $pos) = $res->next()) {
712 $this->arts[$cid][$aid] = new NLArticle($title, $body, $append, $aid, $cid, $pos);
713 }
714 }
715
716 protected function importJSonStoredUFB($json = null)
717 {
718 require_once 'ufbuilder.inc.php';
719 $ufb = $this->nl->getSubscribersUFB();
720 if (is_null($json)) {
721 return new StoredUserFilterBuilder($ufb, new PFC_True());
722 }
723 $export = json_decode($json, true);
724 if (is_null($export)) {
725 PlErrorReport::report("Invalid json while reading NL {$this->nlid}, issue {$this->id}: failed to import '''{$json}'''.");
726 return new StoredUserFilterBuilder($ufb, new PFC_True());
727 }
728 $sufb = new StoredUserFilterBuilder($ufb);
729 $sufb->fillFromExport($export);
730 return $sufb;
731 }
732
733 protected function exportStoredUFBAsJSon()
734 {
735 return json_encode($this->sufb->export());
736 }
737
738 public function id()
739 {
740 return is_null($this->shortname) ? $this->id : $this->shortname;
741 }
742
743 protected function selectId($where)
744 {
745 $res = XDB::query("SELECT IFNULL(ni.short_name, ni.id)
746 FROM newsletter_issues AS ni
747 WHERE ni.state != 'new' AND ni.nlid = {?} AND ${where}
748 LIMIT 1", $this->nl->id);
749 if ($res->numRows() != 1) {
750 return null;
751 }
752 return $res->fetchOneCell();
753 }
754
755 /** Delete this issue
756 * @return True if the issue could be deleted, false otherwise.
757 * Related articles will be deleted through cascading FKs.
758 * If this issue was the last issue for at least one subscriber, the deletion will be aborted.
759 */
760 public function delete()
761 {
762 if ($this->state == self::STATE_NEW) {
763 $res = XDB::query('SELECT COUNT(*)
764 FROM newsletter_ins
765 WHERE last = {?}', $this->id);
766 if ($res->fetchOneCell() > 0) {
767 return false;
768 }
769
770 return XDB::execute('DELETE FROM newsletter_issues
771 WHERE id = {?}', $this->id);
772 } else {
773 return false;
774 }
775 }
776
777 /** Schedule a mailing of this NL
778 * If the 'send_before' field was NULL, it is set to the current time.
779 * @return Boolean Whether the date could be set (false if trying to schedule an already sent NL)
780 */
781 public function scheduleMailing()
782 {
783 if ($this->state == self::STATE_NEW) {
784 $success = XDB::execute('UPDATE newsletter_issues
785 SET state = \'pending\', send_before = IFNULL(send_before, NOW())
786 WHERE id = {?}',
787 $this->id);
788 if ($success) {
789 global $globals;
790 $mailer = new PlMailer('newsletter/notify_scheduled.mail.tpl');
791 $mailer->assign('issue', $this);
792 $mailer->assign('base', $globals->baseurl);
793 $mailer->send();
794 $this->refresh();
795 }
796 return $success;
797 } else {
798 return false;
799 }
800 }
801
802 /** Cancel the scheduled mailing of this NL
803 * @return Boolean: whether the mailing could be cancelled.
804 */
805 public function cancelMailing()
806 {
807 if ($this->state == self::STATE_PENDING) {
808 $success = XDB::execute('UPDATE newsletter_issues
809 SET state = \'new\'
810 WHERE id = {?}', $this->id);
811 if ($success) {
812 $this->refresh();
813 }
814 return $success;
815 } else {
816 return false;
817 }
818 }
819
820 /** Helper function for smarty templates: is this issue editable ?
821 */
822 public function isEditable()
823 {
824 return $this->state == self::STATE_NEW;
825 }
826
827 /** Helper function for smarty templates: is the mailing of this issue scheduled ?
828 */
829 public function isPending()
830 {
831 return $this->state == self::STATE_PENDING;
832 }
833
834 /** Helper function for smarty templates: has this issue been sent ?
835 */
836 public function isSent()
837 {
838 return $this->state == self::STATE_SENT;
839 }
840
841 // }}}
842 // {{{ Navigation
843
844 private $id_prev = null;
845 private $id_next = null;
846 private $id_last = null;
847
848 /** Retrieve ID of the previous issue
849 * That value, once fetched, is cached in the private $id_prev variable.
850 * @return ID of the previous issue.
851 */
852 public function prev()
853 {
854 if (is_null($this->id_prev)) {
855 $this->id_prev = $this->selectId(XDB::format("ni.id < {?} ORDER BY ni.id DESC", $this->id));
856 }
857 return $this->id_prev;
858 }
859
860 /** Retrieve ID of the following issue
861 * That value, once fetched, is cached in the private $id_next variable.
862 * @return ID of the following issue.
863 */
864 public function next()
865 {
866 if (is_null($this->id_next)) {
867 $this->id_next = $this->selectId(XDB::format("ni.id > {?} ORDER BY ni.id", $this->id));
868 }
869 return $this->id_next;
870 }
871
872 /** Retrieve ID of the last issue
873 * That value, once fetched, is cached in the private $id_last variable.
874 * @return ID of the last issue.
875 */
876 public function last()
877 {
878 if (is_null($this->id_last)) {
879 try {
880 $this->id_last = $this->nl->getIssue('last')->id;
881 } catch (MailNotFound $e) {
882 $this->id_last = null;
883 }
884 }
885 return $this->id_last;
886 }
887
888 // }}}
889 // {{{ Edition, articles
890
891 const ERROR_INVALID_REPLY_TO = 'invalid_reply_to';
892 const ERROR_INVALID_SHORTNAME = 'invalid_shortname';
893 const ERROR_INVALID_UFC = 'invalid_ufc';
894 const ERROR_TOO_LONG_UFC = 'too_long_ufc';
895 const ERROR_SQL_SAVE = 'sql_error';
896
897 /** Save the global properties of this NL issue (title&co).
898 */
899 public function save()
900 {
901 $errors = array();
902
903 // Fill the list of fields to update
904 $fields = array(
905 'title' => $this->title,
906 'mail_title' => $this->title_mail,
907 'head' => $this->head,
908 'signature' => $this->signature,
909 );
910
911 if (!empty($this->reply_to) && !isvalid_email($this->reply_to)) {
912 $errors[] = self::ERROR_INVALID_REPLY_TO ;
913 } else {
914 $fields['reply_to'] = $this->reply_to;
915 }
916
917 if ($this->isEditable()) {
918 $fields['date'] = $this->date;
919 if (!preg_match('/^[-a-z0-9]+$/i', $this->shortname) || is_numeric($this->shortname)) {
920 $errors[] = self::ERROR_INVALID_SHORTNAME;
921 } else {
922 $fields['short_name'] = $this->shortname;
923 }
924 if ($this->sufb->isValid() || $this->sufb->isEmpty()) {
925 $fields['sufb_json'] = json_encode($this->sufb->export()->dict());
926 // If sufb_json is too long to be store, we do not store a truncated json and notify the user.
927 // The limit is LONGTEXT's one, ie 2^32 = 4294967296.
928 if (strlen($fields['sufb_json']) > 4294967295) {
929 $errors[] = self::ERROR_TOO_LONG_UFC;
930 }
931 } else {
932 $errors[] = self::ERROR_INVALID_UFC;
933 }
934
935 if ($this->nl->automaticMailingEnabled()) {
936 $fields['send_before'] = ($this->send_before ? $this->send_before : null);
937 }
938 }
939
940 if (count($errors)) {
941 return $errors;
942 }
943 $field_sets = array();
944 foreach ($fields as $key => $value) {
945 $field_sets[] = XDB::format($key . ' = {?}', $value);
946 }
947 XDB::execute('UPDATE newsletter_issues
948 SET ' . implode(', ', $field_sets) . '
949 WHERE id={?}',
950 $this->id);
951 if (XDB::affectedRows()) {
952 $this->refresh();
953 } else {
954 $errors[] = self::ERROR_SQL_SAVE;
955 }
956 return $errors;
957 }
958
959 /** Get an article by number
960 * @p $aid Article ID (among articles of the issue)
961 * @return A NLArticle object, or null if there is no article by that number
962 */
963 public function getArt($aid)
964 {
965 $this->fetchArticles();
966
967 foreach ($this->arts as $category => $artlist) {
968 if (isset($artlist[$aid])) {
969 return $artlist[$aid];
970 }
971 }
972 return null;
973 }
974
975 /** Save an article
976 * @p $a A reference to a NLArticle object (will be modified once saved)
977 */
978 public function saveArticle($a)
979 {
980 $this->fetchArticles();
981
982 // Prevent cid to be 0 (use NULL instead)
983 $a->cid = ($a->cid == 0) ? null : $a->cid;
984 if ($a->aid >= 0) {
985 // Article already exists in DB
986 XDB::execute('UPDATE newsletter_art
987 SET cid = {?}, pos = {?}, title = {?}, body = {?}, append = {?}
988 WHERE id = {?} AND aid = {?}',
989 $a->cid, $a->pos, $a->title, $a->body, $a->append, $this->id, $a->aid);
990 } else {
991 // New article
992 XDB::startTransaction();
993 list($aid, $pos) = XDB::fetchOneRow('SELECT MAX(aid) AS aid, MAX(pos) AS pos
994 FROM newsletter_art AS a
995 WHERE a.id = {?}',
996 $this->id);
997 $a->aid = ++$aid;
998 $a->pos = ($a->pos ? $a->pos : ++$pos);
999 XDB::execute('INSERT INTO newsletter_art (id, aid, cid, pos, title, body, append)
1000 VALUES ({?}, {?}, {?}, {?}, {?}, {?}, {?})',
1001 $this->id, $a->aid, $a->cid, $a->pos,
1002 $a->title, $a->body, $a->append);
1003 XDB::commit();
1004 }
1005 // Update local ID of article
1006 $this->arts[$a->aid] = $a;
1007 }
1008
1009 /** Delete an article by its ID
1010 * @p $aid ID of the article to delete
1011 */
1012 public function delArticle($aid)
1013 {
1014 $this->fetchArticles();
1015
1016 XDB::execute('DELETE FROM newsletter_art WHERE id={?} AND aid={?}', $this->id, $aid);
1017 foreach ($this->arts as $key=>$art) {
1018 unset($this->arts[$key][$aid]);
1019 }
1020 }
1021
1022 // }}}
1023 // {{{ Display
1024
1025 /** Retrieve the title of this issue
1026 * @p $mail Whether we want the normal title or the email subject
1027 * @return Title of the issue
1028 */
1029 public function title($mail = false)
1030 {
1031 return $mail ? $this->title_mail : $this->title;
1032 }
1033
1034 /** Retrieve the head of this issue
1035 * @p $user User for <dear> customization (may be null: no customization)
1036 * @p $type Either 'text' or 'html'
1037 * @return Formatted head of the issue.
1038 */
1039 public function head($user = null, $type = 'text')
1040 {
1041 if (is_null($user)) {
1042 return $this->head;
1043 } else {
1044 $head = $this->head;
1045 $head = str_replace(array('<cher>', '<prenom>', '<nom>'),
1046 array(($user->isFemale() ? 'Chère' : 'Cher'), $user->displayName(), ''),
1047 $head);
1048 return format_text($head, $type, 2, 64);
1049 }
1050 }
1051
1052 /** Retrieve the formatted signature of this issue.
1053 */
1054 public function signature($type = 'text')
1055 {
1056 return format_text($this->signature, $type, 2, 64);
1057 }
1058
1059 /** Get the title of a given category
1060 * @p $cid ID of the category to retrieve
1061 * @return Name of the category
1062 */
1063 public function category($cid)
1064 {
1065 return $this->nl->cats[$cid];
1066 }
1067
1068 /** Add required data to the given $page for proper CSS display
1069 * @p $page Smarty object
1070 * @return Either 'true' (if CSS was added to a page) or the raw CSS to add (when $page is null)
1071 */
1072 public function css($page = null)
1073 {
1074 if (!is_null($page)) {
1075 $page->addCssLink($this->nl->cssFile());
1076 return true;
1077 } else {
1078 $css = file_get_contents(dirname(__FILE__) . '/../htdocs/css/' . $this->nl->cssFile());
1079 return preg_replace('@/\*.*?\*/@us', '', $css);
1080 }
1081 }
1082
1083 /** Set up a smarty page for a 'text' mode render of the issue
1084 * @p $page Smarty object (using the $this->nl->tplFile() template)
1085 * @p $user User to use when rendering the template
1086 */
1087 public function toText($page, $user)
1088 {
1089 $this->fetchArticles();
1090
1091 $this->css($page);
1092 $page->assign('prefix', null);
1093 $page->assign('is_mail', false);
1094 $page->assign('mail_part', 'text');
1095 $page->assign('user', $user);
1096 $page->assign('hash', null);
1097 $this->assignData($page);
1098 }
1099
1100 /** Set up a smarty page for a 'html' mode render of the issue
1101 * @p $page Smarty object (using the $this->nl->tplFile() template)
1102 * @p $user User to use when rendering the template
1103 */
1104 public function toHtml($page, $user)
1105 {
1106 $this->fetchArticles();
1107
1108 $this->css($page);
1109 $page->assign('prefix', $this->nl->prefix() . '/show/' . $this->id());
1110 $page->assign('is_mail', false);
1111 $page->assign('mail_part', 'html');
1112 $page->assign('user', $user);
1113 $page->assign('hash', null);
1114 $this->assignData($page);
1115 }
1116
1117 /** Set all 'common' data for the page (those which are required for both web and email rendering)
1118 * @p $smarty Smarty object (e.g page) which should be filled
1119 */
1120 protected function assignData($smarty)
1121 {
1122 $this->fetchArticles();
1123
1124 $smarty->assign_by_ref('issue', $this);
1125 $smarty->assign_by_ref('nl', $this->nl);
1126 }
1127
1128 // }}}
1129 // {{{ Mailing
1130
1131 /** Check whether this issue is empty
1132 * An issue is empty if the email has no title (or the default one), or no articles and an empty head.
1133 */
1134 public function isEmpty()
1135 {
1136 return $this->title_mail == '' || $this->title_mail == 'to be continued' || (count($this->arts) == 0 && strlen($this->head) == 0);
1137 }
1138
1139 /** Retrieve the 'Send before' date, in a clean format.
1140 */
1141 public function getSendBeforeDate()
1142 {
1143 return strftime('%Y-%m-%d', strtotime($this->send_before));
1144 }
1145
1146 /** Retrieve the 'Send before' time (i.e hour), in a clean format.
1147 */
1148 public function getSendBeforeTime()
1149 {
1150 return strtotime($this->send_before);
1151 }
1152
1153 /** Create a hash based on some additional data
1154 * $line Line-specific data (to prevent two hashes generated at the same time to be the same)
1155 */
1156 protected static function createHash($line)
1157 {
1158 $hash = implode(time(), $line) . rand();
1159 $hash = md5($hash);
1160 return $hash;
1161 }
1162
1163 /** Send this issue to the given user, reusing an existing hash if provided.
1164 * @p $user User to whom the issue should be mailed
1165 * @p $hash Optional hash to use in the 'unsubscribe' link; if null, another one will be generated.
1166 */
1167 public function sendTo($user, $hash = null)
1168 {
1169 $this->fetchArticles();
1170
1171 if (is_null($hash)) {
1172 $hash = XDB::fetchOneCell("SELECT hash
1173 FROM newsletter_ins
1174 WHERE uid = {?} AND nlid = {?}",
1175 $user->id(), $this->nl->id);
1176 }
1177 if (is_null($hash)) {
1178 $hash = self::createHash(array($user->displayName(), $user->fullName(),
1179 $user->isFemale(), $user->isEmailFormatHtml(),
1180 rand(), "X.org rulez"));
1181 XDB::execute("UPDATE newsletter_ins as ni
1182 SET ni.hash = {?}
1183 WHERE ni.uid = {?} AND ni.nlid = {?}",
1184 $hash, $user->id(), $this->nl->id);
1185 }
1186
1187 $mailer = new PlMailer($this->nl->tplFile());
1188 $this->assignData($mailer);
1189 $mailer->assign('is_mail', true);
1190 $mailer->assign('user', $user);
1191 $mailer->assign('prefix', null);
1192 $mailer->assign('hash', $hash);
1193 if (!empty($this->reply_to)) {
1194 $mailer->addHeader('Reply-To', $this->reply_to);
1195 }
1196 $mailer->sendTo($user);
1197 }
1198
1199 /** Select a subset of subscribers which should receive the newsletter.
1200 * NL-Specific selections (not yet received, is subscribed) are done when sending.
1201 * @return A PlFilterCondition.
1202 */
1203 protected function getRecipientsUFC()
1204 {
1205 return $this->sufb->getUFC();
1206 }
1207
1208 /** Check whether a given user may see this issue.
1209 * @p $user User whose access should be checked
1210 * @return Whether he may access the issue
1211 */
1212 public function checkUser($user = null)
1213 {
1214 if ($user == null) {
1215 $user = S::user();
1216 }
1217 $uf = new UserFilter($this->getRecipientsUFC());
1218 return $uf->checkUser($user);
1219 }
1220
1221 /** Sent this issue to all valid recipients
1222 * @return Number of issues sent
1223 */
1224 public function sendToAll()
1225 {
1226 $this->fetchArticles();
1227
1228 XDB::execute('UPDATE newsletter_issues
1229 SET state = \'sent\', date=CURDATE()
1230 WHERE id = {?}',
1231 $this->id);
1232
1233 $ufc = new PFC_And($this->getRecipientsUFC(), new UFC_NLSubscribed($this->nl->id, $this->id), new UFC_HasValidEmail());
1234 $uf = new UserFilter($ufc, array(new UFO_IsAdmin(), new UFO_Uid()));
1235 $limit = new PlLimit(self::BATCH_SIZE);
1236 $global_sent = array();
1237
1238 while (true) {
1239 $sent = array();
1240 $users = $uf->getUsers($limit);
1241 if (count($users) == 0) {
1242 break;
1243 }
1244 foreach ($users as $user) {
1245 if (array_key_exists($user->id(), $global_sent)) {
1246 Platal::page()->kill('Sending the same newsletter issue ' . $this->id . ' to user ' . $user->id() . ' twice, something must be wrong.');
1247 }
1248 $sent[] = $user->id();
1249 $global_sent[$user->id()] = true;
1250 $this->sendTo($user, $hash);
1251 }
1252 XDB::execute("UPDATE newsletter_ins
1253 SET last = {?}
1254 WHERE nlid = {?} AND uid IN {?}", $this->id, $this->nl->id, $sent);
1255
1256 sleep(60);
1257 }
1258 return count($global_sent);
1259 }
1260
1261 // }}}
1262 }
1263
1264 // }}}
1265 // {{{ class NLArticle
1266
1267 class NLArticle
1268 {
1269 // Maximum number of lines per article
1270 const MAX_LINES_PER_ARTICLE = 8;
1271 const MAX_CHARACTERS_PER_LINE = 68;
1272
1273 // {{{ properties
1274
1275 public $aid;
1276 public $cid;
1277 public $pos;
1278 public $title;
1279 public $body;
1280 public $append;
1281
1282 // }}}
1283 // {{{ constructor
1284
1285 function __construct($title='', $body='', $append='', $aid=-1, $cid=0, $pos=0)
1286 {
1287 $this->body = $body;
1288 $this->title = $title;
1289 $this->append = $append;
1290 $this->aid = $aid;
1291 $this->cid = $cid;
1292 $this->pos = $pos;
1293 }
1294
1295 // }}}
1296 // {{{ function title()
1297
1298 public function title()
1299 { return trim($this->title); }
1300
1301 // }}}
1302 // {{{ function body()
1303
1304 public function body()
1305 { return trim($this->body); }
1306
1307 // }}}
1308 // {{{ function append()
1309
1310 public function append()
1311 { return trim($this->append); }
1312
1313 // }}}
1314 // {{{ function toText()
1315
1316 public function toText($hash = null, $login = null)
1317 {
1318 $title = '*'.$this->title().'*';
1319 $body = MiniWiki::WikiToText($this->body, true);
1320 $app = MiniWiki::WikiToText($this->append, false, 4);
1321 $text = trim("$title\n\n$body\n\n$app")."\n";
1322 if (!is_null($hash) && !is_null($login)) {
1323 $text = str_replace('%HASH%', "$hash/$login", $text);
1324 } else {
1325 $text = str_replace('%HASH%', '', $text);
1326 }
1327 return $text;
1328 }
1329
1330 // }}}
1331 // {{{ function toHtml()
1332
1333 public function toHtml($hash = null, $login = null)
1334 {
1335 $title = "<h2 class='xorg_nl'><a id='art{$this->aid}'></a>".pl_entities($this->title()).'</h2>';
1336 $body = MiniWiki::WikiToHTML($this->body);
1337 $app = MiniWiki::WikiToHTML($this->append);
1338
1339 $art = "$title\n";
1340 $art .= "<div class='art'>\n$body\n";
1341 if ($app) {
1342 $art .= "<div class='app'>$app</div>";
1343 }
1344 $art .= "</div>\n";
1345 if (!is_null($hash) && !is_null($login)) {
1346 $art = str_replace('%HASH%', "$hash/$login", $art);
1347 } else {
1348 $art = str_replace('%HASH%', '', $art);
1349 }
1350
1351 return $art;
1352 }
1353
1354 // }}}
1355 // {{{ function check()
1356
1357 public function check()
1358 {
1359 $rest = $this->remain();
1360
1361 return $rest['remaining_lines'] >= 0;
1362 }
1363
1364 // }}}
1365 // {{{ function remain()
1366
1367 public function remain()
1368 {
1369 $text = MiniWiki::WikiToText($this->body);
1370 $array = explode("\n", wordwrap($text, self::MAX_CHARACTERS_PER_LINE));
1371 $lines_count = 0;
1372 foreach ($array as $line) {
1373 if (trim($line) != '') {
1374 ++$lines_count;
1375 }
1376 }
1377
1378 return array(
1379 'remaining_lines' => self::MAX_LINES_PER_ARTICLE - $lines_count,
1380 'remaining_characters_for_last_line' => self::MAX_CHARACTERS_PER_LINE - strlen($array[count($array) - 1])
1381 );
1382 }
1383 // }}}
1384 // {{{ function parseUrlsFromArticle()
1385
1386 protected function parseUrlsFromArticle()
1387 {
1388 $email_regex = '([a-z0-9.\-+_\$]+@([\-.+_]?[a-z0-9])+)';
1389 $url_regex = '((https?|ftp)://[a-zA-Z0-9._%#+/?=&~-]+)';
1390 $regex = '{' . $email_regex . '|' . $url_regex . '}i';
1391
1392 $matches = array();
1393 $body_matches = array();
1394 if (preg_match_all($regex, $this->body(), $body_matches)) {
1395 $matches = array_merge($matches, $body_matches[0]);
1396 }
1397
1398 $append_matches = array();
1399 if (preg_match_all($regex, $this->append(), $append_matches)) {
1400 $matches = array_merge($matches, $append_matches[0]);
1401 }
1402
1403 return $matches;
1404 }
1405
1406 // }}}
1407 // {{{ function getLinkIps()
1408
1409 public function getLinkIps(&$blacklist_host_resolution_count)
1410 {
1411 $matches = $this->parseUrlsFromArticle();
1412 $article_ips = array();
1413
1414 if (!empty($matches)) {
1415 global $globals;
1416
1417 foreach ($matches as $match) {
1418 $host = parse_url($match, PHP_URL_HOST);
1419 if ($host == '') {
1420 list(, $host) = explode('@', $match);
1421 }
1422
1423 if ($blacklist_host_resolution_count >= $globals->mail->blacklist_host_resolution_limit) {
1424 break;
1425 }
1426
1427 if (!preg_match('/^(' . str_replace(' ', '|', $globals->mail->domain_whitelist) . ')$/i', $host)) {
1428 $article_ips = array_merge($article_ips, array(gethostbyname($host) => $host));
1429 ++$blacklist_host_resolution_count;
1430 }
1431 }
1432 }
1433
1434 return $article_ips;
1435 }
1436
1437 // }}}
1438 }
1439
1440 // }}}
1441
1442 // {{{ Functions
1443
1444 function format_text($input, $format, $indent = 0, $width = 68)
1445 {
1446 if ($format == 'text') {
1447 return MiniWiki::WikiToText($input, true, $indent, $width, "title");
1448 }
1449 return MiniWiki::WikiToHTML($input, "title");
1450 }
1451
1452 // function enriched_to_text($input,$html=false,$just=false,$indent=0,$width=68)
1453
1454 // }}}
1455
1456 // vim:set et sw=4 sts=4 sws=4 enc=utf-8:
1457 ?>