Fixes anchors in emails/redirect.
[platal.git] / include / newsletter.inc.php
CommitLineData
0337d704 1<?php
2/***************************************************************************
12262f13 3 * Copyright (C) 2003-2011 Polytechnique.org *
0337d704 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
84163d58
RB
22// {{{ class MailNotFound
23
24class MailNotFound extends Exception {
25}
26
27// }}}
0337d704 28
0337d704 29// {{{ class NewsLetter
30
84163d58 31class NewsLetter
0337d704 32{
84163d58
RB
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;
0337d704 41
84163d58
RB
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
5e9a29ed
SJ
50 // Searches on mutiple fields
51 const SEARCH_ALL = 'all';
52 const SEARCH_TITLE = 'title';
53
54
84163d58
RB
55 // {{{ Constructor, NewsLetter retrieval (forGroup, getAll)
56
57 public function __construct($id)
0337d704 58 {
84163d58
RB
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);
ed76a506 163 }
ed76a506 164 if (!$res->numRows()) {
84163d58
RB
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();
ed76a506 179 }
ad6ca77d 180 }
84163d58 181
26ba053e 182 return new NLIssue($id, $this);
84163d58
RB
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.
b8bcbe02 239 * @p $create Whether to create an empty issue if no pending issue exist.
84163d58
RB
240 * @return Either null, or a NL object.
241 */
b8bcbe02 242 public function getPendingIssue($create = false)
84163d58
RB
243 {
244 $res = XDB::query('SELECT MAX(id)
245 FROM newsletter_issues
246 WHERE nlid = {?} AND state = \'new\'',
247 $this->id);
8dc39e6a
RB
248 $id = $res->fetchOneCell();
249 if ($id != null) {
84163d58 250 return new NLIssue($id, $this);
b8bcbe02
RB
251 } else if ($create) {
252 $id = $this->createPending();
253 return new NLIssue($id, $this);
84163d58
RB
254 } else {
255 return null;
256 }
257 }
258
5e9a29ed
SJ
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
84163d58
RB
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($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 return true;
346 }
347
348 /** Subscribe a user to a newsletter
349 * @p $user User to subscribe to the newsletter; if null, use current user.
350 */
351 public function subscribe($user = null)
352 {
353 if (is_null($user)) {
354 $user = S::user();
355 }
356 if (self::maySubscribe($user)) {
357 XDB::execute('INSERT IGNORE INTO newsletter_ins (nlid, uid, last, hash)
358 VALUES ({?}, {?}, NULL, hash)',
359 $this->id, $user->id());
360 }
361 }
362
363 /** Retrieve subscription state of a user
364 * @p $user Target user; if null, use current user.
365 * @return Boolean: true if the user has subscribed to the NL.
366 */
367 public function subscriptionState($user = null)
368 {
369 if (is_null($user)) {
370 $user = S::user();
371 }
372 $res = XDB::query('SELECT 1
373 FROM newsletter_ins
374 WHERE nlid = {?} AND uid = {?}',
375 $this->id, $user->id());
376 return ($res->numRows() == 1);
377 }
378
379 /** Get the count of subscribers to the NL.
380 * @return Number of subscribers.
381 */
382 public function subscriberCount()
383 {
384 return XDB::fetchOneCell('SELECT COUNT(uid)
385 FROM newsletter_ins
386 WHERE nlid = {?}', $this->id);
387 }
388
389 /** Get the number of subscribers to the NL whose last received mailing was $last.
390 * @p $last ID of the issue for which subscribers should be counted.
391 * @return Number of subscribers
392 */
393 public function subscriberCountForLast($last)
394 {
395 return XDB::fetchOneCell('SELECT COUNT(uid)
396 FROM newsletter_ins
397 WHERE nlid = {?} AND last = {?}', $this->id, $last);
398 }
399
400 /** Retrieve the list of newsletters a user has subscribed to
401 * @p $user User whose subscriptions should be retrieved (if null, use session user).
402 * @return Array of newsletter IDs
403 */
404 public static function getUserSubscriptions($user = null)
405 {
406 if (is_null($user)) {
407 $user = S::user();
408 }
409 $res = XDB::query('SELECT nlid
410 FROM newsletter_ins
411 WHERE uid = {?}',
412 $user->id());
413 return $res->fetchColumn();
414 }
415
416 /** Retrieve the UserFilterBuilder for subscribers to this NL.
417 * This is the place where NL-specific filters may be allowed or prevented.
418 * @p $envprefix Prefix to use for env fields (cf. UserFilterBuilder)
419 * @return A UserFilterBuilder object using the given env prefix
420 */
421 public function getSubscribersUFB($envprefix = '')
422 {
423 require_once 'ufbuilder.inc.php';
424 return new UFB_NewsLetter($this->criteria, $envprefix);
425 }
426
427 // }}}
428 // {{{ Permissions related functions
429
430 /** For later use: check whether a given user may subscribe to this newsletter.
431 * @p $user User whose access should be checked
432 * @return Boolean: whether the user may subscribe to the NL.
433 */
434 public function maySubscribe($user = null)
435 {
436 return true;
437 }
438
439 /** Whether a given user may edit this newsletter
440 * @p $uid UID of the user whose perms should be checked (if null, use current user)
441 * @return Boolean: whether the user may edit the NL
442 */
443 public function mayEdit($user = null)
444 {
445 if (is_null($user)) {
446 $user = S::user();
447 }
448 if ($user->checkPerms('admin')) {
449 return true;
450 }
451 $res = XDB::query('SELECT perms
452 FROM group_members
453 WHERE asso_id = {?} AND uid = {?}',
454 $this->group_id, $user->id());
455 return ($res->numRows() && $res->fetchOneCell() == 'admin');
456 }
457
458 /** Whether a given user may submit articles to this newsletter using X.org validation system
459 * @p $user User whose access should be checked (if null, use current user)
460 * @return Boolean: whether the user may submit articles
461 */
462 public function maySubmit($user = null)
463 {
464 // Submission of new articles is only enabled for the X.org NL (and forbidden when viewing issues on X.net)
465 return ($this->group == self::GROUP_XORG && !isset($GLOBALS['IS_XNET_SITE']));
466 }
467
468 // }}}
469 // {{{ Display-related functions: cssFile, tplFile, prefix, admin_prefix, admin_links_enabled, automatic_mailings_enabled
470
471 /** Get the name of the css file used to display this newsletter.
472 */
473 public function cssFile()
474 {
475 if ($this->custom_css) {
476 $base = $this->group;
477 } else {
478 $base = self::FORMAT_DEFAULT_GROUP;
479 }
480 return 'nl.' . $base . '.css';
481 }
482
483 /** Get the name of the template file used to display this newsletter.
484 */
485 public function tplFile()
486 {
487 if ($this->custom_css) {
488 $base = $this->group;
489 } else {
490 $base = self::FORMAT_DEFAULT_GROUP;
491 }
492 return 'newsletter/nl.' . $base . '.mail.tpl';
493 }
494
495 /** Get the prefix leading to the page for this NL
496 * Only X.org / AX / X groups may be seen on X.org.
497 */
c8fc2813 498 public function prefix($enforce_xnet=true)
84163d58
RB
499 {
500 if (!empty($GLOBALS['IS_XNET_SITE'])) {
501 return $this->group . '/nl';
502 }
503 switch ($this->group) {
504 case self::GROUP_XORG:
505 return 'nl';
506 case self::GROUP_AX:
507 return 'ax';
508 case self::GROUP_EP:
509 return 'epletter';
510 default:
511 // Don't display groups NLs on X.org
c8fc2813 512 assert(!$enforce_xnet);
84163d58
RB
513 }
514 }
515
516 /** Get the prefix to use for all 'admin' pages of this NL.
517 */
e9700d3e 518 public function adminPrefix($enforce_xnet=true)
84163d58
RB
519 {
520 if (!empty($GLOBALS['IS_XNET_SITE'])) {
521 return $this->group . '/admin/nl';
522 }
523 switch ($this->group) {
524 case self::GROUP_XORG:
525 return 'admin/newsletter';
526 case self::GROUP_AX:
527 return 'ax/admin';
528 case self::GROUP_EP:
529 return 'epletter/admin';
530 default:
531 // Don't display groups NLs on X.org
e9700d3e 532 assert(!$enforce_xnet);
84163d58
RB
533 }
534 }
535
536 /** Hack used to remove "admin" links on X.org page on X.net
537 * The 'admin' links are enabled for all pages, except for X.org when accessing NL through X.net
538 */
539 public function adminLinksEnabled()
540 {
541 return ($this->group != self::GROUP_XORG || !isset($GLOBALS['IS_XNET_SITE']));
542 }
543
544 /** Automatic mailings are disabled for X.org NL.
545 */
546 public function automaticMailingEnabled()
547 {
548 return $this->group != self::GROUP_XORG;
549 }
550
551 public function hasCustomCss()
552 {
553 return $this->custom_css;
554 }
555
556 // }}}
557}
558
559// }}}
560
561// {{{ class NLIssue
562
563// A NLIssue is an issue of a given NewsLetter
564class NLIssue
565{
566 protected $nlid; // Id of the newsletter
567
568 const STATE_NEW = 'new'; // New, currently being edited
569 const STATE_PENDING = 'pending'; // Ready for mailing
570 const STATE_SENT = 'sent'; // Sent
571
572 public $nl; // Related NL
573
574 public $id; // Id of this issue of the newsletter
575 public $shortname; // Shortname for this issue
576 public $title; // Title of this issue
577 public $title_mail; // Title of the email
578 public $state; // State of the issue (one of the STATE_ values)
579 public $sufb; // Environment to use to generate the UFC through an UserFilterBuilder
580
581 public $date; // Date at which this issue was sent
582 public $send_before; // Date at which issue should be sent
583 public $head; // Foreword of the issue (or body for letters with no articles)
584 public $signature; // Signature of the letter
585 public $arts = array(); // Articles of the issue
586
587 const BATCH_SIZE = 60; // Number of emails to send every minute.
588
589 // {{{ Constructor, id-related functions
590
591 /** Build a NewsLetter.
592 * @p $id: ID of the issue (unique among all newsletters)
593 * @p $nl: Optional argument containing an already built NewsLetter object.
594 */
595 function __construct($id, $nl = null, $fetch_articles = true)
596 {
597 return $this->fetch($id, $nl, $fetch_articles);
598 }
599
600 protected function refresh()
601 {
602 return $this->fetch($this->id, $this->nl, false);
603 }
604
605 protected function fetch($id, $nl = null, $fetch_articles = true)
606 {
607 // Load this issue
608 $res = XDB::query('SELECT nlid, short_name, date, send_before, state, sufb_json,
609 title, mail_title, head, signature
610 FROM newsletter_issues
611 WHERE id = {?}',
612 $id);
613 if (!$res->numRows()) {
9da70671
SJ
614 throw new MailNotFound();
615 }
84163d58
RB
616 $issue = $res->fetchOneAssoc();
617 if ($nl && $nl->id == $issue['nlid']) {
618 $this->nl = $nl;
619 } else {
620 $this->nl = new NewsLetter($issue['nlid']);
621 }
622 $this->id = $id;
b9518dc7 623 $this->nlid = $issue['nlid'];
84163d58
RB
624 $this->shortname = $issue['short_name'];
625 $this->date = $issue['date'];
626 $this->send_before = $issue['send_before'];
627 $this->state = $issue['state'];
628 $this->title = $issue['title'];
629 $this->title_mail = $issue['mail_title'];
630 $this->head = $issue['head'];
631 $this->signature = $issue['signature'];
632 $this->sufb = $this->importJSonStoredUFB($issue['sufb_json']);
0337d704 633
84163d58
RB
634 if ($fetch_articles) {
635 $this->fetchArticles();
636 }
637 }
0337d704 638
84163d58
RB
639 protected function fetchArticles($force = false)
640 {
641 if (count($this->arts) && !$force) {
642 return;
ed76a506 643 }
eaf30d86 644
84163d58 645 // Load the articles
ed76a506 646 $res = XDB::iterRow(
84163d58
RB
647 'SELECT a.title, a.body, a.append, a.aid, a.cid, a.pos
648 FROM newsletter_art AS a
649 INNER JOIN newsletter_issues AS ni USING(id)
650 LEFT JOIN newsletter_cat AS c ON (a.cid = c.cid)
651 WHERE a.id = {?}
652 ORDER BY c.pos, a.pos',
653 $this->id);
ed76a506 654 while (list($title, $body, $append, $aid, $cid, $pos) = $res->next()) {
84163d58
RB
655 $this->arts[$cid][$aid] = new NLArticle($title, $body, $append, $aid, $cid, $pos);
656 }
657 }
658
659 protected function importJSonStoredUFB($json = null)
660 {
661 require_once 'ufbuilder.inc.php';
662 $ufb = $this->nl->getSubscribersUFB();
663 if (is_null($json)) {
664 return new StoredUserFilterBuilder($ufb, new PFC_True());
665 }
666 $export = json_decode($json, true);
667 if (is_null($export)) {
668 PlErrorReport::report("Invalid json while reading NL {$this->nlid}, issue {$this->id}: failed to import '''{$json}'''.");
669 return new StoredUserFilterBuilder($ufb, new PFC_True());
670 }
671 $sufb = new StoredUserFilterBuilder($ufb);
672 $sufb->fillFromExport($export);
673 return $sufb;
674 }
675
676 protected function exportStoredUFBAsJSon()
677 {
678 return json_encode($this->sufb->export());
679 }
680
681 public function id()
682 {
683 return is_null($this->shortname) ? $this->id : $this->shortname;
684 }
685
686 protected function selectId($where)
687 {
688 $res = XDB::query("SELECT IFNULL(ni.short_name, ni.id)
689 FROM newsletter_issues AS ni
690 WHERE ni.state != 'new' AND ni.nlid = {?} AND ${where}
691 LIMIT 1", $this->nl->id);
692 if ($res->numRows() != 1) {
693 return null;
694 }
695 return $res->fetchOneCell();
696 }
697
698 /** Delete this issue
699 * @return True if the issue could be deleted, false otherwise.
700 * Related articles will be deleted through cascading FKs.
701 * If this issue was the last issue for at least one subscriber, the deletion will be aborted.
702 */
703 public function delete()
704 {
705 if ($this->state == self::STATE_NEW) {
706 $res = XDB::query('SELECT COUNT(*)
707 FROM newsletter_ins
708 WHERE last = {?}', $this->id);
709 if ($res->fetchOneCell() > 0) {
710 return false;
711 }
712
713 return XDB::execute('DELETE FROM newsletter_issues
714 WHERE id = {?}', $this->id);
715 } else {
716 return false;
717 }
718 }
719
720 /** Schedule a mailing of this NL
721 * If the 'send_before' field was NULL, it is set to the current time.
722 * @return Boolean Whether the date could be set (false if trying to schedule an already sent NL)
723 */
724 public function scheduleMailing()
725 {
726 if ($this->state == self::STATE_NEW) {
727 $success = XDB::execute('UPDATE newsletter_issues
728 SET state = \'pending\', send_before = IFNULL(send_before, NOW())
729 WHERE id = {?}',
730 $this->id);
731 if ($success) {
d5524e7b
RB
732 global $globals;
733 $mailer = new PlMailer('newsletter/notify_scheduled.mail.tpl');
e9700d3e 734 $mailer->assign('issue', $this);
d5524e7b 735 $mailer->assign('base', $globals->baseurl);
d5524e7b 736 $mailer->send();
84163d58
RB
737 $this->refresh();
738 }
739 return $success;
740 } else {
741 return false;
742 }
743 }
744
745 /** Cancel the scheduled mailing of this NL
746 * @return Boolean: whether the mailing could be cancelled.
747 */
748 public function cancelMailing()
749 {
750 if ($this->state == self::STATE_PENDING) {
751 $success = XDB::execute('UPDATE newsletter_issues
a386dd97 752 SET state = \'new\'
84163d58
RB
753 WHERE id = {?}', $this->id);
754 if ($success) {
755 $this->refresh();
756 }
757 return $success;
758 } else {
759 return false;
760 }
761 }
762
763 /** Helper function for smarty templates: is this issue editable ?
764 */
765 public function isEditable()
766 {
767 return $this->state == self::STATE_NEW;
768 }
769
770 /** Helper function for smarty templates: is the mailing of this issue scheduled ?
771 */
772 public function isPending()
773 {
774 return $this->state == self::STATE_PENDING;
775 }
776
777 /** Helper function for smarty templates: has this issue been sent ?
778 */
779 public function isSent()
780 {
781 return $this->state == self::STATE_SENT;
782 }
783
784 // }}}
785 // {{{ Navigation
786
787 private $id_prev = null;
788 private $id_next = null;
789 private $id_last = null;
790
791 /** Retrieve ID of the previous issue
792 * That value, once fetched, is cached in the private $id_prev variable.
793 * @return ID of the previous issue.
794 */
795 public function prev()
796 {
797 if (is_null($this->id_prev)) {
798 $this->id_prev = $this->selectId(XDB::format("ni.id < {?} ORDER BY ni.id DESC", $this->id));
799 }
800 return $this->id_prev;
801 }
802
803 /** Retrieve ID of the following issue
804 * That value, once fetched, is cached in the private $id_next variable.
805 * @return ID of the following issue.
806 */
807 public function next()
808 {
809 if (is_null($this->id_next)) {
810 $this->id_next = $this->selectId(XDB::format("ni.id > {?} ORDER BY ni.id", $this->id));
811 }
812 return $this->id_next;
813 }
814
815 /** Retrieve ID of the last issue
816 * That value, once fetched, is cached in the private $id_last variable.
817 * @return ID of the last issue.
818 */
819 public function last()
820 {
821 if (is_null($this->id_last)) {
822 $this->id_last = $this->nl->getIssue('last')->id;
ed76a506 823 }
84163d58 824 return $this->id_last;
0337d704 825 }
826
84163d58
RB
827 // }}}
828 // {{{ Edition, articles
829
830 const ERROR_INVALID_SHORTNAME = 'invalid_shortname';
831 const ERROR_INVALID_UFC = 'invalid_ufc';
9ce149f3 832 const ERROR_TOO_LONG_UFC = 'too_long_ufc';
84163d58
RB
833 const ERROR_SQL_SAVE = 'sql_error';
834
835 /** Save the global properties of this NL issue (title&co).
836 */
4882f4a5 837 public function save()
0337d704 838 {
84163d58
RB
839 $errors = array();
840
841 // Fill the list of fields to update
842 $fields = array(
843 'title' => $this->title,
844 'mail_title' => $this->title_mail,
845 'head' => $this->head,
846 'signature' => $this->signature,
847 );
848
849 if ($this->isEditable()) {
850 $fields['date'] = $this->date;
851 if (!preg_match('/^[-a-z0-9]+$/i', $this->shortname) || is_numeric($this->shortname)) {
852 $errors[] = self::ERROR_INVALID_SHORTNAME;
853 } else {
854 $fields['short_name'] = $this->shortname;
855 }
856 if ($this->sufb->isValid() || $this->sufb->isEmpty()) {
857 $fields['sufb_json'] = json_encode($this->sufb->export()->dict());
9ce149f3
SJ
858 // If sufb_json is too long to be store, we do not store a truncated json and notify the user.
859 // The limit is LONGTEXT's one, ie 2^32 = 4294967296.
860 if (strlen($fields['sufb_json']) > 4294967295) {
861 $errors[] = self::ERROR_TOO_LONG_UFC;
862 }
84163d58
RB
863 } else {
864 $errors[] = self::ERROR_INVALID_UFC;
865 }
866
867 if ($this->nl->automaticMailingEnabled()) {
868 $fields['send_before'] = ($this->send_before ? $this->send_before : null);
869 }
870 }
871
872 if (count($errors)) {
873 return $errors;
874 }
875 $field_sets = array();
876 foreach ($fields as $key => $value) {
877 $field_sets[] = XDB::format($key . ' = {?}', $value);
878 }
879 XDB::execute('UPDATE newsletter_issues
880 SET ' . implode(', ', $field_sets) . '
881 WHERE id={?}',
882 $this->id);
883 if (XDB::affectedRows()) {
884 $this->refresh();
885 } else {
886 $errors[] = self::ERROR_SQL_SAVE;
887 }
888 return $errors;
ed76a506 889 }
eaf30d86 890
84163d58
RB
891 /** Get an article by number
892 * @p $aid Article ID (among articles of the issue)
893 * @return A NLArticle object, or null if there is no article by that number
894 */
4882f4a5 895 public function getArt($aid)
0337d704 896 {
84163d58
RB
897 $this->fetchArticles();
898
899 foreach ($this->arts as $category => $artlist) {
900 if (isset($artlist[$aid])) {
901 return $artlist[$aid];
0337d704 902 }
11f44323 903 }
904 return null;
0337d704 905 }
906
84163d58 907 /** Save an article
26ba053e 908 * @p $a A reference to a NLArticle object (will be modified once saved)
84163d58 909 */
26ba053e 910 public function saveArticle($a)
0337d704 911 {
84163d58
RB
912 $this->fetchArticles();
913
914 // Prevent cid to be 0 (use NULL instead)
915 $a->cid = ($a->cid == 0) ? null : $a->cid;
916 if ($a->aid >= 0) {
917 // Article already exists in DB
00ba8a74
SJ
918 XDB::execute('UPDATE newsletter_art
919 SET cid = {?}, pos = {?}, title = {?}, body = {?}, append = {?}
920 WHERE id = {?} AND aid = {?}',
84163d58 921 $a->cid, $a->pos, $a->title, $a->body, $a->append, $this->id, $a->aid);
11f44323 922 } else {
84163d58 923 // New article
00ba8a74
SJ
924 XDB::startTransaction();
925 list($aid, $pos) = XDB::fetchOneRow('SELECT MAX(aid) AS aid, MAX(pos) AS pos
926 FROM newsletter_art AS a
927 WHERE a.id = {?}',
84163d58
RB
928 $this->id);
929 $a->aid = ++$aid;
930 $a->pos = ($a->pos ? $a->pos : ++$pos);
00ba8a74
SJ
931 XDB::execute('INSERT INTO newsletter_art (id, aid, cid, pos, title, body, append)
932 VALUES ({?}, {?}, {?}, {?}, {?}, {?}, {?})',
84163d58
RB
933 $this->id, $a->aid, $a->cid, $a->pos,
934 $a->title, $a->body, $a->append);
00ba8a74 935 XDB::commit();
11f44323 936 }
84163d58
RB
937 // Update local ID of article
938 $this->arts[$a->aid] = $a;
0337d704 939 }
eaf30d86 940
84163d58
RB
941 /** Delete an article by its ID
942 * @p $aid ID of the article to delete
943 */
4882f4a5 944 public function delArticle($aid)
0337d704 945 {
84163d58
RB
946 $this->fetchArticles();
947
948 XDB::execute('DELETE FROM newsletter_art WHERE id={?} AND aid={?}', $this->id, $aid);
949 foreach ($this->arts as $key=>$art) {
950 unset($this->arts[$key][$aid]);
11f44323 951 }
0337d704 952 }
953
84163d58
RB
954 // }}}
955 // {{{ Display
956
957 /** Retrieve the title of this issue
958 * @p $mail Whether we want the normal title or the email subject
959 * @return Title of the issue
960 */
961 public function title($mail = false)
4882f4a5 962 {
84163d58 963 return $mail ? $this->title_mail : $this->title;
4882f4a5 964 }
0337d704 965
84163d58
RB
966 /** Retrieve the head of this issue
967 * @p $user User for <dear> customization (may be null: no customization)
968 * @p $type Either 'text' or 'html'
969 * @return Formatted head of the issue.
970 */
971 public function head($user = null, $type = 'text')
0337d704 972 {
84163d58
RB
973 if (is_null($user)) {
974 return $this->head;
975 } else {
976 $head = $this->head;
977 $head = str_replace(array('<cher>', '<prenom>', '<nom>'),
978 array(($user->isFemale() ? 'Chère' : 'Cher'), $user->displayName(), ''),
979 $head);
980 return format_text($head, $type, 2, 64);
981 }
eaf30d86 982 }
0337d704 983
84163d58
RB
984 /** Retrieve the formatted signature of this issue.
985 */
986 public function signature($type = 'text')
0337d704 987 {
84163d58
RB
988 return format_text($this->signature, $type, 2, 64);
989 }
990
991 /** Get the title of a given category
992 * @p $cid ID of the category to retrieve
993 * @return Name of the category
994 */
995 public function category($cid)
996 {
997 return $this->nl->cats[$cid];
998 }
999
1000 /** Add required data to the given $page for proper CSS display
1001 * @p $page Smarty object
1002 * @return Either 'true' (if CSS was added to a page) or the raw CSS to add (when $page is null)
1003 */
26ba053e 1004 public function css($page = null)
84163d58
RB
1005 {
1006 if (!is_null($page)) {
1007 $page->addCssLink($this->nl->cssFile());
1008 return true;
1009 } else {
1010 $css = file_get_contents(dirname(__FILE__) . '/../htdocs/css/' . $this->nl->cssFile());
1011 return preg_replace('@/\*.*?\*/@us', '', $css);
1012 }
1013 }
1014
1015 /** Set up a smarty page for a 'text' mode render of the issue
1016 * @p $page Smarty object (using the $this->nl->tplFile() template)
1017 * @p $user User to use when rendering the template
1018 */
26ba053e 1019 public function toText($page, $user)
84163d58
RB
1020 {
1021 $this->fetchArticles();
1022
1023 $this->css($page);
1024 $page->assign('prefix', null);
1025 $page->assign('is_mail', false);
1026 $page->assign('mail_part', 'text');
1027 $page->assign('user', $user);
1028 $page->assign('hash', null);
1029 $this->assignData($page);
eaf30d86
PH
1030 }
1031
84163d58
RB
1032 /** Set up a smarty page for a 'html' mode render of the issue
1033 * @p $page Smarty object (using the $this->nl->tplFile() template)
1034 * @p $user User to use when rendering the template
1035 */
26ba053e 1036 public function toHtml($page, $user)
4882f4a5 1037 {
84163d58
RB
1038 $this->fetchArticles();
1039
1040 $this->css($page);
1041 $page->assign('prefix', $this->nl->prefix() . '/show/' . $this->id());
1042 $page->assign('is_mail', false);
1043 $page->assign('mail_part', 'html');
1044 $page->assign('user', $user);
1045 $page->assign('hash', null);
1046 $this->assignData($page);
0337d704 1047 }
1048
84163d58
RB
1049 /** Set all 'common' data for the page (those which are required for both web and email rendering)
1050 * @p $smarty Smarty object (e.g page) which should be filled
1051 */
26ba053e 1052 protected function assignData($smarty)
4882f4a5 1053 {
84163d58
RB
1054 $this->fetchArticles();
1055
1056 $smarty->assign_by_ref('issue', $this);
1057 $smarty->assign_by_ref('nl', $this->nl);
4882f4a5 1058 }
1059
84163d58
RB
1060 // }}}
1061 // {{{ Mailing
d5524e7b
RB
1062
1063 /** Check whether this issue is empty
1064 * An issue is empty if the email has no title (or the default one), or no articles and an empty head.
1065 */
1066 public function isEmpty()
1067 {
a386dd97 1068 return $this->title_mail == '' || $this->title_mail == 'to be continued' || (count($this->arts) == 0 && strlen($this->head) == 0);
d5524e7b
RB
1069 }
1070
84163d58
RB
1071 /** Retrieve the 'Send before' date, in a clean format.
1072 */
1073 public function getSendBeforeDate()
4882f4a5 1074 {
84163d58 1075 return strftime('%Y-%m-%d', strtotime($this->send_before));
4882f4a5 1076 }
1077
84163d58
RB
1078 /** Retrieve the 'Send before' time (i.e hour), in a clean format.
1079 */
1080 public function getSendBeforeTime()
4882f4a5 1081 {
84163d58 1082 return strtotime($this->send_before);
4882f4a5 1083 }
1084
84163d58
RB
1085 /** Create a hash based on some additional data
1086 * $line Line-specific data (to prevent two hashes generated at the same time to be the same)
1087 */
1088 protected static function createHash($line)
4882f4a5 1089 {
84163d58
RB
1090 $hash = implode(time(), $line) . rand();
1091 $hash = md5($hash);
1092 return $hash;
4882f4a5 1093 }
1094
84163d58
RB
1095 /** Send this issue to the given user, reusing an existing hash if provided.
1096 * @p $user User to whom the issue should be mailed
1097 * @p $hash Optional hash to use in the 'unsubscribe' link; if null, another one will be generated.
1098 */
1099 public function sendTo($user, $hash = null)
4882f4a5 1100 {
84163d58
RB
1101 $this->fetchArticles();
1102
1103 if (is_null($hash)) {
1104 $hash = XDB::fetchOneCell("SELECT hash
1105 FROM newsletter_ins
1106 WHERE uid = {?} AND nlid = {?}",
1107 $user->id(), $this->nl->id);
1108 }
1109 if (is_null($hash)) {
1110 $hash = self::createHash(array($user->displayName(), $user->fullName(),
1111 $user->isFemale(), $user->isEmailFormatHtml(),
1112 rand(), "X.org rulez"));
1113 XDB::execute("UPDATE newsletter_ins as ni
1114 SET ni.hash = {?}
1115 WHERE ni.uid = {?} AND ni.nlid = {?}",
1116 $hash, $user->id(), $this->nl->id);
1117 }
1118
1119 $mailer = new PlMailer($this->nl->tplFile());
1120 $this->assignData($mailer);
1121 $mailer->assign('is_mail', true);
1122 $mailer->assign('user', $user);
1123 $mailer->assign('prefix', null);
1124 $mailer->assign('hash', $hash);
1125 $mailer->sendTo($user);
4882f4a5 1126 }
84163d58
RB
1127
1128 /** Select a subset of subscribers which should receive the newsletter.
1129 * NL-Specific selections (not yet received, is subscribed) are done when sending.
1130 * @return A PlFilterCondition.
1131 */
1132 protected function getRecipientsUFC()
1133 {
1134 return $this->sufb->getUFC();
1135 }
1136
1137 /** Check whether a given user may see this issue.
1138 * @p $user User whose access should be checked
1139 * @return Whether he may access the issue
1140 */
1141 public function checkUser($user = null)
1142 {
1143 if ($user == null) {
1144 $user = S::user();
1145 }
1146 $uf = new UserFilter($this->getRecipientsUFC());
1147 return $uf->checkUser($user);
1148 }
1149
1150 /** Sent this issue to all valid recipients
1151 * @return Number of issues sent
1152 */
1153 public function sendToAll()
1154 {
1155 $this->fetchArticles();
1156
1157 XDB::execute('UPDATE newsletter_issues
1158 SET state = \'sent\', date=CURDATE()
1159 WHERE id = {?}',
1160 $this->id);
1161
df6d9034 1162 $ufc = new PFC_And($this->getRecipientsUFC(), new UFC_NLSubscribed($this->nl->id, $this->id), new UFC_HasValidEmail());
84163d58 1163 $emailsCount = 0;
d5524e7b 1164 $uf = new UserFilter($ufc, array(new UFO_IsAdmin(), new UFO_Uid()));
00278954 1165 $limit = new PlLimit(self::BATCH_SIZE);
84163d58
RB
1166
1167 while (true) {
1168 $sent = array();
00278954 1169 $users = $uf->getUsers($limit);
84163d58
RB
1170 if (count($users) == 0) {
1171 return $emailsCount;
1172 }
1173 foreach ($users as $user) {
1174 $sent[] = $user->id();
1175 $this->sendTo($user, $hash);
1176 ++$emailsCount;
1177 }
1178 XDB::execute("UPDATE newsletter_ins
1179 SET last = {?}
1180 WHERE nlid = {?} AND uid IN {?}", $this->id, $this->nl->id, $sent);
1181
1182 sleep(60);
1183 }
1184 return $emailsCount;
1185 }
1186
1187 // }}}
0337d704 1188}
1189
1190// }}}
1191// {{{ class NLArticle
1192
1193class NLArticle
1194{
84163d58 1195 // Maximum number of lines per article
0fd5be99
SJ
1196 const MAX_LINES_PER_ARTICLE = 8;
1197 const MAX_CHARACTERS_PER_LINE = 68;
84163d58 1198
0337d704 1199 // {{{ properties
eaf30d86 1200
84163d58
RB
1201 public $aid;
1202 public $cid;
1203 public $pos;
1204 public $title;
1205 public $body;
1206 public $append;
0337d704 1207
1208 // }}}
1209 // {{{ constructor
eaf30d86 1210
4882f4a5 1211 function __construct($title='', $body='', $append='', $aid=-1, $cid=0, $pos=0)
0337d704 1212 {
84163d58
RB
1213 $this->body = $body;
1214 $this->title = $title;
1215 $this->append = $append;
1216 $this->aid = $aid;
1217 $this->cid = $cid;
1218 $this->pos = $pos;
0337d704 1219 }
1220
1221 // }}}
1222 // {{{ function title()
1223
4882f4a5 1224 public function title()
84163d58 1225 { return trim($this->title); }
0337d704 1226
1227 // }}}
1228 // {{{ function body()
eaf30d86 1229
4882f4a5 1230 public function body()
84163d58 1231 { return trim($this->body); }
eaf30d86 1232
0337d704 1233 // }}}
1234 // {{{ function append()
eaf30d86 1235
4882f4a5 1236 public function append()
84163d58 1237 { return trim($this->append); }
0337d704 1238
1239 // }}}
1240 // {{{ function toText()
1241
e0e77c5a 1242 public function toText($hash = null, $login = null)
0337d704 1243 {
4882f4a5 1244 $title = '*'.$this->title().'*';
84163d58
RB
1245 $body = MiniWiki::WikiToText($this->body, true);
1246 $app = MiniWiki::WikiToText($this->append, false, 4);
e0e77c5a
FB
1247 $text = trim("$title\n\n$body\n\n$app")."\n";
1248 if (!is_null($hash) && !is_null($login)) {
1249 $text = str_replace('%HASH%', "$hash/$login", $text);
1250 } else {
1251 $text = str_replace('%HASH%', '', $text);
1252 }
1253 return $text;
0337d704 1254 }
1255
1256 // }}}
1257 // {{{ function toHtml()
1258
e0e77c5a 1259 public function toHtml($hash = null, $login = null)
0337d704 1260 {
84163d58
RB
1261 $title = "<h2 class='xorg_nl'><a id='art{$this->aid}'></a>".pl_entities($this->title()).'</h2>';
1262 $body = MiniWiki::WikiToHTML($this->body);
1263 $app = MiniWiki::WikiToHTML($this->append);
eaf30d86 1264
4882f4a5 1265 $art = "$title\n";
1266 $art .= "<div class='art'>\n$body\n";
1267 if ($app) {
0337d704 1268 $art .= "<div class='app'>$app</div>";
1269 }
4882f4a5 1270 $art .= "</div>\n";
e0e77c5a
FB
1271 if (!is_null($hash) && !is_null($login)) {
1272 $art = str_replace('%HASH%', "$hash/$login", $art);
1273 } else {
1274 $art = str_replace('%HASH%', '', $art);
1275 }
eaf30d86 1276
4882f4a5 1277 return $art;
0337d704 1278 }
1279
1280 // }}}
1281 // {{{ function check()
1282
4882f4a5 1283 public function check()
0337d704 1284 {
0fd5be99
SJ
1285 $rest = $this->remain();
1286
1287 return $rest['remaining_lines'] >= 0;
1288 }
1289
1290 // }}}
1291 // {{{ function remain()
1292
1293 public function remain()
1294 {
1295 $text = MiniWiki::WikiToText($this->body);
1296 $array = explode("\n", wordwrap($text, self::MAX_CHARACTERS_PER_LINE));
1297 $lines_count = 0;
1298 foreach ($array as $line) {
1299 if (trim($line) != '') {
1300 ++$lines_count;
0337d704 1301 }
1302 }
0337d704 1303
0fd5be99
SJ
1304 return array(
1305 'remaining_lines' => self::MAX_LINES_PER_ARTICLE - $lines_count,
1306 'remaining_characters_for_last_line' => self::MAX_CHARACTERS_PER_LINE - strlen($array[count($array) - 1])
1307 );
1308 }
0337d704 1309 // }}}
9e2a6a32
SJ
1310 // {{{ function parseUrlsFromArticle()
1311
84163d58 1312 protected function parseUrlsFromArticle()
9e2a6a32
SJ
1313 {
1314 $email_regex = '([a-z0-9.\-+_\$]+@([\-.+_]?[a-z0-9])+)';
1315 $url_regex = '((https?|ftp)://[a-zA-Z0-9._%#+/?=&~-]+)';
1316 $regex = '{' . $email_regex . '|' . $url_regex . '}i';
1317
1318 $matches = array();
1319 $body_matches = array();
1320 if (preg_match_all($regex, $this->body(), $body_matches)) {
1321 $matches = array_merge($matches, $body_matches[0]);
1322 }
1323
1324 $append_matches = array();
1325 if (preg_match_all($regex, $this->append(), $append_matches)) {
1326 $matches = array_merge($matches, $append_matches[0]);
1327 }
1328
1329 return $matches;
1330 }
1331
1332 // }}}
1333 // {{{ function getLinkIps()
1334
955109ba 1335 public function getLinkIps(&$blacklist_host_resolution_count)
9e2a6a32
SJ
1336 {
1337 $matches = $this->parseUrlsFromArticle();
1338 $article_ips = array();
1339
1340 if (!empty($matches)) {
1341 global $globals;
1342
1343 foreach ($matches as $match) {
1344 $host = parse_url($match, PHP_URL_HOST);
1345 if ($host == '') {
1346 list(, $host) = explode('@', $match);
1347 }
1348
955109ba 1349 if ($blacklist_host_resolution_count >= $globals->mail->blacklist_host_resolution_limit) {
9e2a6a32
SJ
1350 break;
1351 }
1352
955109ba 1353 if (!preg_match('/^(' . str_replace(' ', '|', $globals->mail->domain_whitelist) . ')$/i', $host)) {
9e2a6a32 1354 $article_ips = array_merge($article_ips, array(gethostbyname($host) => $host));
955109ba 1355 ++$blacklist_host_resolution_count;
9e2a6a32
SJ
1356 }
1357 }
1358 }
1359
1360 return $article_ips;
1361 }
1362
1363 // }}}
0337d704 1364}
1365
1366// }}}
0337d704 1367
84163d58
RB
1368// {{{ Functions
1369
1370function format_text($input, $format, $indent = 0, $width = 68)
1371{
1372 if ($format == 'text') {
1373 return MiniWiki::WikiToText($input, true, $indent, $width, "title");
1374 }
1375 return MiniWiki::WikiToHTML($input, "title");
1376}
1377
1378// function enriched_to_text($input,$html=false,$just=false,$indent=0,$width=68)
1379
1380// }}}
1381
a7de4ef7 1382// vim:set et sw=4 sts=4 sws=4 enc=utf-8:
0337d704 1383?>