2 /***************************************************************************
3 * Copyright (C) 2003-2013 Polytechnique.org *
4 * http://opensource.polytechnique.org/ *
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. *
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. *
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 *
19 * 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA *
20 ***************************************************************************/
22 // {{{ class MailNotFound
24 class MailNotFound
extends Exception
{
29 // {{{ class NewsLetter
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
40 protected $custom_css = false
;
42 // Base name to use instead of the group short name for NLs without a custom CSS
43 const FORMAT_DEFAULT_GROUP
= 'default';
45 // Diminutif of X.net groups with a specific NL view
46 const GROUP_XORG
= 'Polytechnique.org';
47 const GROUP_COMMUNITY
= 'Annonces';
48 const GROUP_AX
= 'AX';
49 const GROUP_EP
= 'Ecole';
50 const GROUP_FX
= 'FX';
52 // Searches on mutiple fields
53 const SEARCH_ALL
= 'all';
54 const SEARCH_TITLE
= 'title';
57 // {{{ Constructor, NewsLetter retrieval (forGroup, getAll)
59 public function __construct($id)
62 $res = XDB
::query('SELECT nls.group_id, g.diminutif AS group_name,
63 nls.name AS nl_name, nls.custom_css, nls.criteria
64 FROM newsletters AS nls
65 LEFT JOIN groups AS g ON (nls.group_id = g.id)
68 if (!$res->numRows()) {
69 throw new MailNotFound();
72 $data = $res->fetchOneAssoc();
74 $this->group_id
= $data['group_id'];
75 $this->group
= $data['group_name'];
76 $this->name
= $data['nl_name'];
77 $this->custom_css
= $data['custom_css'];
78 $this->criteria
= new PlFlagSet($data['criteria']);
80 // Load the categories
86 while (list($cid, $title) = $res->next()) {
87 $this->cats
[$cid] = $title;
91 /** Retrieve the NL associated with a given group.
92 * @p $group Short name of the group
93 * @return A NewsLetter object, or null if the group doesn't have a NL.
95 public static function forGroup($group)
97 $res = XDB
::query('SELECT nls.id
98 FROM newsletters AS nls
99 LEFT JOIN groups AS g ON (nls.group_id = g.id)
100 WHERE g.diminutif = {?}', $group);
101 if (!$res->numRows()) {
104 return new NewsLetter($res->fetchOneCell());
107 /** Retrieve all newsletters
108 * @return An array of $id => NewsLetter objects
110 public static function getAll($sort = 'id', $order = 'ASC')
112 $res = XDB
::fetchAllAssoc('SELECT n.id, g.nom AS group_name, n.name, n.custom_css, n.criteria, g.diminutif AS group_link
113 FROM newsletters AS n
114 INNER JOIN groups AS g ON (n.group_id = g.id)
115 ORDER BY ' . $sort . ' ' . $order);
120 // {{{ Issue retrieval
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)
125 public static function getIssuesToSend()
127 $res = XDB
::query('SELECT id
128 FROM newsletter_issues
129 WHERE state = \'pending\' AND send_before <= NOW()');
131 foreach ($res->fetchColumn() as $id) {
132 $issues[$id] = new NLIssue($id);
137 /** Retrieve a given issue of this NewsLetter
138 * @p $name Name or ID of the issue to retrieve.
139 * @return A NLIssue object.
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.
145 public function getIssue($name = null
, $only_sent = true
)
148 if ($name == 'last') {
150 $where = 'state = \'sent\' AND ';
154 $res = XDB
::query('SELECT MAX(id)
155 FROM newsletter_issues
156 WHERE ' . $where . ' nlid = {?}',
159 $res = XDB
::query('SELECT id
160 FROM newsletter_issues
161 WHERE nlid = {?} AND (id = {?} OR short_name = {?})',
162 $this->id
, $name, $name);
164 if (!$res->numRows()) {
165 throw new MailNotFound();
167 $id = $res->fetchOneCell();
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();
177 // Create a new, empty issue, and return it
178 $id = $this->createPending();
182 return new NLIssue($id, $this);
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.
189 public function createPending()
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\'',
196 return XDB
::insertId();
199 /** Return all sent issues of this newsletter.
200 * @return An array of (id => NLIssue)
202 public function listSentIssues($check_user = false
, $user = null
)
204 if ($check_user && $user == null
) {
208 $res = XDB
::query('SELECT id
209 FROM newsletter_issues
210 WHERE nlid = {?} AND state = \'sent\'
211 ORDER BY date DESC', $this->id
);
213 foreach ($res->fetchColumn() as $id) {
214 $issue = new NLIssue($id, $this, false
);
215 if (!$check_user ||
$issue->checkUser($user)) {
216 $issues[$id] = $issue;
222 /** Return all issues of this newsletter, including invalid and sent.
223 * @return An array of (id => NLIssue)
225 public function listAllIssues()
227 $res = XDB
::query('SELECT id
228 FROM newsletter_issues
230 ORDER BY FIELD(state, \'pending\', \'new\') DESC, date DESC', $this->id
);
232 foreach ($res->fetchColumn() as $id) {
233 $issues[$id] = new NLIssue($id, $this, false
);
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.
242 public function getPendingIssue($create = false
)
244 $res = XDB
::query('SELECT MAX(id)
245 FROM newsletter_issues
246 WHERE nlid = {?} AND state = \'new\'',
248 $id = $res->fetchOneCell();
250 return new NLIssue($id, $this);
251 } else if ($create) {
252 $id = $this->createPending();
253 return new NLIssue($id, $this);
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.
264 public function issueSearch($search, $field, $user)
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 . ')';
272 $where = $field . $search;
274 $list = XDB
::fetchColumn('SELECT DISTINCT(id)
275 FROM newsletter_issues
276 WHERE nlid = {?} AND state = \'sent\' AND ' . $where . '
281 foreach ($list as $id) {
282 $issue = new NLIssue($id, $this, false
);
283 if ($issue->checkUser($user)) {
290 public function articleSearch($search, $field, $user)
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 . ')';
296 $where = 'a.' . $field . $search;
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 . '
303 ORDER BY i.date DESC, a.aid',
307 foreach ($list as $item) {
308 $issue = new NLIssue($item['id'], $this, false
);
309 if ($issue->checkUser($user)) {
317 // {{{ Subscription related function
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.
324 public function unsubscribe($issue_id = null
, $uid = null
, $hash = false
)
326 if (is_null($uid) && $hash) {
327 // Unable to unsubscribe from an empty hash
330 $user = is_null($uid) ? S
::user()->id() : $uid;
331 $field = $hash ?
'hash' : 'uid';
332 $res = XDB
::query('SELECT uid
334 WHERE nlid = {?} AND ' . $field . ' = {?}',
336 if (!$res->numRows()) {
337 // No subscribed user with that UID/hash
340 $user = $res->fetchOneCell();
342 XDB
::execute('DELETE FROM newsletter_ins
343 WHERE nlid = {?} AND uid = {?}',
345 if (!is_null($issue_id)) {
346 XDB
::execute('UPDATE newsletter_issues
347 SET unsubscribe = unsubscribe + 1
354 /** Subscribe a user to a newsletter
355 * @p $user User to subscribe to the newsletter; if null, use current user.
357 public function subscribe($user = null
)
359 if (is_null($user)) {
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());
369 /** Subscribe a batch of users to a newsletter.
370 * This skips 'maySubscribe' test.
372 * @p $user_ids Array of user IDs to subscribe to the newsletter.
374 public function bulkSubscribe($user_ids)
376 // TODO: use a 'bulkMaySubscribe'.
377 XDB
::execute('INSERT IGNORE INTO newsletter_ins (nlid, uid, last, hash)
378 SELECT {?}, a.uid, NULL, NULL
381 $this->id
, $user_ids);
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.
388 public function subscriptionState($user = null
)
390 if (is_null($user)) {
393 $res = XDB
::query('SELECT 1
395 WHERE nlid = {?} AND uid = {?}',
396 $this->id
, $user->id());
397 return ($res->numRows() == 1);
400 /** Get the count of subscribers to the NL.
401 * @return Number of subscribers.
403 public function subscriberCount($lost = null
, $sex = null
, $grade = null
, $first_promo = null
, $last_promo = null
)
405 $cond = new PFC_And(new UFC_NLSubscribed($this->id
));
406 if (!is_null($sex)) {
407 $cond->addChild(new UFC_Sex($sex));
409 if (!is_null($grade)) {
410 $cond->addChild(new UFC_Promo('>=', $grade, $first_promo));
411 $cond->addChild(new UFC_Promo('<=', $grade, $last_promo));
413 if (!($lost === null
)) {
414 if ($lost === true
) {
415 $cond->addChild(new PFC_Not(new UFC_HasEmailRedirect()));
417 $cond->addChild(new UFC_HasEmailRedirect());
420 $uf = new UserFilter($cond);
421 return $uf->getTotalCount();
424 /** Get the count of subscribers with non valid redirection.
426 public function lostSubscriberCount($sex = null
)
428 return $this->subscriberCount(true
, $sex);
431 /** Get the number of subscribers to the NL whose last received mailing was $last.
432 * @p $last ID of the issue for which subscribers should be counted.
433 * @return Number of subscribers
435 public function subscriberCountForLast($last)
437 return XDB
::fetchOneCell('SELECT COUNT(uid)
439 WHERE nlid = {?} AND last = {?}', $this->id
, $last);
442 /** Retrieve the list of newsletters a user has subscribed to
443 * @p $user User whose subscriptions should be retrieved (if null, use session user).
444 * @return Array of newsletter IDs
446 public static function getUserSubscriptions($user = null
)
448 if (is_null($user)) {
451 $res = XDB
::query('SELECT nlid
455 return $res->fetchColumn();
458 /** Retrieve the UserFilterBuilder for subscribers to this NL.
459 * This is the place where NL-specific filters may be allowed or prevented.
460 * @p $envprefix Prefix to use for env fields (cf. UserFilterBuilder)
461 * @return A UserFilterBuilder object using the given env prefix
463 public function getSubscribersUFB($envprefix = '')
465 require_once 'ufbuilder.inc.php';
466 return new UFB_NewsLetter($this->criteria
, $envprefix);
470 // {{{ Permissions related functions
472 /** For later use: check whether a given user may subscribe to this newsletter.
473 * @p $user User whose access should be checked
474 * @return Boolean: whether the user may subscribe to the NL.
476 public function maySubscribe($user = null
)
481 /** Whether a given user may edit this newsletter
482 * @p $uid UID of the user whose perms should be checked (if null, use current user)
483 * @return Boolean: whether the user may edit the NL
485 public function mayEdit($user = null
)
487 if (is_null($user)) {
490 if ($user->checkPerms('admin')) {
493 $res = XDB
::query('SELECT perms
495 WHERE asso_id = {?} AND uid = {?}',
496 $this->group_id
, $user->id());
497 return ($res->numRows() && $res->fetchOneCell() == 'admin');
500 /** Whether a given user may submit articles to this newsletter using X.org validation system
501 * @p $user User whose access should be checked (if null, use current user)
502 * @return Boolean: whether the user may submit articles
504 public function maySubmit($user = null
)
506 // Submission of new articles is only enabled for the X.org NL and the
507 // community letter (and forbidden when viewing issues on X.net)
509 ($this->group
== self
::GROUP_XORG ||
$this->group
== self
::GROUP_COMMUNITY
)
510 && !isset($GLOBALS['IS_XNET_SITE']));
514 // {{{ Display-related functions: cssFile, tplFile, prefix, admin_prefix, admin_links_enabled, automatic_mailings_enabled
516 /** Get the name of the css file used to display this newsletter.
518 public function cssFile()
520 if ($this->custom_css
) {
521 $base = $this->group
;
523 $base = self
::FORMAT_DEFAULT_GROUP
;
525 return 'nl.' . $base . '.css';
528 /** Get the name of the template file used to display this newsletter.
530 public function tplFile()
532 if ($this->custom_css
) {
533 $base = $this->group
;
535 $base = self
::FORMAT_DEFAULT_GROUP
;
537 return 'newsletter/nl.' . $base . '.mail.tpl';
540 /** Get the prefix leading to the page for this NL
541 * Only X.org / AX / X groups may be seen on X.org.
543 public function prefix($enforce_xnet=true
, $with_group=true
)
545 if (!empty($GLOBALS['IS_XNET_SITE'])) {
547 return $this->group
. '/nl';
552 switch ($this->group
) {
553 case self
::GROUP_XORG
:
555 case self
::GROUP_COMMUNITY
:
564 // Don't display groups NLs on X.org
565 assert(!$enforce_xnet);
569 /** Get the prefix to use for all 'admin' pages of this NL.
571 public function adminPrefix($enforce_xnet=true
, $with_group=true
)
573 if (!empty($GLOBALS['IS_XNET_SITE'])) {
575 return $this->group
. '/admin/nl';
580 switch ($this->group
) {
581 case self
::GROUP_XORG
:
582 return 'admin/newsletter';
583 case self
::GROUP_COMMUNITY
:
584 return 'comletter/admin';
588 return 'epletter/admin';
590 return 'fxletter/admin';
592 // Don't display groups NLs on X.org
593 assert(!$enforce_xnet);
597 /** Get the prefix to use for all 'stat' pages of this NL.
599 public function statPrefix($enforce_xnet = true
, $with_group = true
)
601 if (!empty($GLOBALS['IS_XNET_SITE'])) {
603 return $this->group
. '/stat/nl';
608 switch ($this->group
) {
609 case self
::GROUP_XORG
:
610 return 'stat/newsletter';
611 case self
::GROUP_COMMUNITY
:
612 return 'comletter/stat';
616 return 'epletter/stat';
618 return 'fxletter/stat';
620 // Don't display groups NLs on X.org
621 assert(!$enforce_xnet);
625 /** Get a full URL to a newsletter
627 public function fullUrl()
629 switch ($this->group
) {
630 case self
::GROUP_XORG
:
631 return 'https://www.polytechnique.org/nl';
632 case self
::GROUP_COMMUNITY
:
633 return 'https://www.polytechnique.org/comletter';
635 return 'https://www.polytechnique.org/ax';
637 return 'https://www.polytechnique.org/epletter';
639 return 'https://www.polytechnique.org/fxletter';
641 return 'http://www.polytechnique.net/' . $this->group
. '/nl';
645 /** Get links for nl pages.
647 public function adminLinks()
650 'index' => array('link' => $this->prefix(), 'title' => 'Archives'),
651 'admin' => array('link' => $this->adminPrefix(), 'title' => 'Administrer'),
652 'stats' => array('link' => $this->statPrefix(), 'title' => 'Statistiques')
656 /** Hack used to remove "admin" links on X.org page on X.net
657 * The 'admin' links are enabled for all pages, except for X.org when accessing NL through X.net
659 public function adminLinksEnabled()
661 return ($this->group
!= self
::GROUP_XORG ||
!isset($GLOBALS['IS_XNET_SITE']));
664 /** Automatic mailings are disabled for X.org NL.
666 public function automaticMailingEnabled()
668 return $this->group
!= self
::GROUP_XORG
;
671 public function hasCustomCss()
673 return $this->custom_css
;
676 public function canSyncWithGroup()
678 switch ($this->group
) {
679 case self
::GROUP_XORG
:
680 case self
::GROUP_COMMUNITY
:
697 // A NLIssue is an issue of a given NewsLetter
700 protected $nlid; // Id of the newsletter
702 const STATE_NEW
= 'new'; // New, currently being edited
703 const STATE_PENDING
= 'pending'; // Ready for mailing
704 const STATE_SENT
= 'sent'; // Sent
706 public $nl; // Related NL
708 public $id; // Id of this issue of the newsletter
709 public $shortname; // Shortname for this issue
710 public $title; // Title of this issue
711 public $title_mail; // Title of the email
712 public $state; // State of the issue (one of the STATE_ values)
713 public $sufb; // Environment to use to generate the UFC through an UserFilterBuilder
715 public $date; // Date at which this issue was sent
716 public $send_before; // Date at which issue should be sent
717 public $head; // Foreword of the issue (or body for letters with no articles)
718 public $signature; // Signature of the letter
719 public $reply_to; // Adress to reply to the message (can be empty)
720 public $arts = array(); // Articles of the issue
722 const BATCH_SIZE
= 60; // Number of emails to send every minute.
724 // {{{ Constructor, id-related functions
726 /** Build a NewsLetter.
727 * @p $id: ID of the issue (unique among all newsletters)
728 * @p $nl: Optional argument containing an already built NewsLetter object.
730 function __construct($id, $nl = null
, $fetch_articles = true
)
732 return $this->fetch($id, $nl, $fetch_articles);
735 protected function refresh()
737 return $this->fetch($this->id
, $this->nl
, false
);
740 protected function fetch($id, $nl = null
, $fetch_articles = true
)
743 $res = XDB
::query('SELECT nlid, short_name, date, send_before, state, sufb_json,
744 title, mail_title, head, signature, reply_to
745 FROM newsletter_issues
748 if (!$res->numRows()) {
749 throw new MailNotFound();
751 $issue = $res->fetchOneAssoc();
752 if ($nl && $nl->id
== $issue['nlid']) {
755 $this->nl
= new NewsLetter($issue['nlid']);
758 $this->nlid
= $issue['nlid'];
759 $this->shortname
= $issue['short_name'];
760 $this->date
= $issue['date'];
761 $this->send_before
= $issue['send_before'];
762 $this->state
= $issue['state'];
763 $this->title
= $issue['title'];
764 $this->title_mail
= $issue['mail_title'];
765 $this->head
= $issue['head'];
766 $this->signature
= $issue['signature'];
767 $this->reply_to
= $issue['reply_to'];
768 $this->sufb
= $this->importJSonStoredUFB($issue['sufb_json']);
770 if ($fetch_articles) {
771 $this->fetchArticles();
775 protected function fetchArticles($force = false
)
777 if (count($this->arts
) && !$force) {
783 'SELECT a.title, a.body, a.append, a.aid, a.cid, a.pos
784 FROM newsletter_art AS a
785 INNER JOIN newsletter_issues AS ni USING(id)
786 LEFT JOIN newsletter_cat AS c ON (a.cid = c.cid)
788 ORDER BY c.pos, a.pos',
790 while (list($title, $body, $append, $aid, $cid, $pos) = $res->next()) {
791 $this->arts
[$cid][$aid] = new NLArticle($title, $body, $append, $aid, $cid, $pos);
795 protected function importJSonStoredUFB($json = null
)
797 require_once 'ufbuilder.inc.php';
798 $ufb = $this->nl
->getSubscribersUFB();
799 if (is_null($json)) {
800 return new StoredUserFilterBuilder($ufb, new PFC_True());
802 $export = json_decode($json, true
);
803 if (is_null($export)) {
804 PlErrorReport
::report("Invalid json while reading NL {$this->nlid}, issue {$this->id}: failed to import '''{$json}'''.");
805 return new StoredUserFilterBuilder($ufb, new PFC_True());
807 $sufb = new StoredUserFilterBuilder($ufb);
808 $sufb->fillFromExport($export);
812 protected function exportStoredUFBAsJSon()
814 return json_encode($this->sufb
->export());
819 return is_null($this->shortname
) ?
$this->id
: $this->shortname
;
822 protected function selectId($where)
824 $res = XDB
::query("SELECT IFNULL(ni.short_name, ni.id)
825 FROM newsletter_issues AS ni
826 WHERE ni.state != 'new' AND ni.nlid = {?} AND ${where}
827 LIMIT 1", $this->nl
->id
);
828 if ($res->numRows() != 1) {
831 return $res->fetchOneCell();
834 /** Delete this issue
835 * @return True if the issue could be deleted, false otherwise.
836 * Related articles will be deleted through cascading FKs.
837 * If this issue was the last issue for at least one subscriber, the deletion will be aborted.
839 public function delete()
841 if ($this->state
== self
::STATE_NEW
) {
842 $res = XDB
::query('SELECT COUNT(*)
844 WHERE last = {?}', $this->id
);
845 if ($res->fetchOneCell() > 0) {
849 return XDB
::execute('DELETE FROM newsletter_issues
850 WHERE id = {?}', $this->id
);
856 /** Schedule a mailing of this NL
857 * If the 'send_before' field was NULL, it is set to the current time.
858 * @return Boolean Whether the date could be set (false if trying to schedule an already sent NL)
860 public function scheduleMailing()
862 if ($this->state
== self
::STATE_NEW
) {
863 $success = XDB
::execute('UPDATE newsletter_issues
864 SET state = \'pending\', send_before = IFNULL(send_before, NOW())
869 $mailer = new PlMailer('newsletter/notify_scheduled.mail.tpl');
870 $mailer->assign('issue', $this);
871 $mailer->assign('base', $globals->baseurl
);
881 /** Cancel the scheduled mailing of this NL
882 * @return Boolean: whether the mailing could be cancelled.
884 public function cancelMailing()
886 if ($this->state
== self
::STATE_PENDING
) {
887 $success = XDB
::execute('UPDATE newsletter_issues
889 WHERE id = {?}', $this->id
);
899 /** Helper function for smarty templates: is this issue editable ?
901 public function isEditable()
903 return $this->state
== self
::STATE_NEW
;
906 /** Helper function for smarty templates: is the mailing of this issue scheduled ?
908 public function isPending()
910 return $this->state
== self
::STATE_PENDING
;
913 /** Helper function for smarty templates: has this issue been sent ?
915 public function isSent()
917 return $this->state
== self
::STATE_SENT
;
923 private $id_prev = null
;
924 private $id_next = null
;
925 private $id_last = null
;
927 /** Retrieve ID of the previous issue
928 * That value, once fetched, is cached in the private $id_prev variable.
929 * @return ID of the previous issue.
931 public function prev()
933 if (is_null($this->id_prev
)) {
934 $this->id_prev
= $this->selectId(XDB
::format("ni.id < {?} ORDER BY ni.id DESC", $this->id
));
936 return $this->id_prev
;
939 /** Retrieve ID of the following issue
940 * That value, once fetched, is cached in the private $id_next variable.
941 * @return ID of the following issue.
943 public function next()
945 if (is_null($this->id_next
)) {
946 $this->id_next
= $this->selectId(XDB
::format("ni.id > {?} ORDER BY ni.id", $this->id
));
948 return $this->id_next
;
951 /** Retrieve ID of the last issue
952 * That value, once fetched, is cached in the private $id_last variable.
953 * @return ID of the last issue.
955 public function last()
957 if (is_null($this->id_last
)) {
959 $this->id_last
= $this->nl
->getIssue('last')->id
;
960 } catch (MailNotFound
$e) {
961 $this->id_last
= null
;
964 return $this->id_last
;
968 // {{{ Edition, articles
970 const ERROR_INVALID_REPLY_TO
= 'invalid_reply_to';
971 const ERROR_INVALID_SHORTNAME
= 'invalid_shortname';
972 const ERROR_INVALID_UFC
= 'invalid_ufc';
973 const ERROR_TOO_LONG_UFC
= 'too_long_ufc';
974 const ERROR_SQL_SAVE
= 'sql_error';
976 /** Save the global properties of this NL issue (title&co).
978 public function save()
982 // Fill the list of fields to update
984 'title' => $this->title
,
985 'mail_title' => $this->title_mail
,
986 'head' => $this->head
,
987 'signature' => $this->signature
,
990 if (!empty($this->reply_to
) && !isvalid_email($this->reply_to
)) {
991 $errors[] = self
::ERROR_INVALID_REPLY_TO
;
993 $fields['reply_to'] = $this->reply_to
;
996 if ($this->isEditable()) {
997 $fields['date'] = $this->date
;
998 if (!preg_match('/^[-a-z0-9]+$/i', $this->shortname
) ||
is_numeric($this->shortname
)) {
999 $errors[] = self
::ERROR_INVALID_SHORTNAME
;
1001 $fields['short_name'] = $this->shortname
;
1003 if ($this->sufb
->isValid() ||
$this->sufb
->isEmpty()) {
1004 $fields['sufb_json'] = json_encode($this->sufb
->export()->dict());
1005 // If sufb_json is too long to be store, we do not store a truncated json and notify the user.
1006 // The limit is LONGTEXT's one, ie 2^32 = 4294967296.
1007 if (strlen($fields['sufb_json']) > 4294967295) {
1008 $errors[] = self
::ERROR_TOO_LONG_UFC
;
1011 $errors[] = self
::ERROR_INVALID_UFC
;
1014 if ($this->nl
->automaticMailingEnabled()) {
1015 $fields['send_before'] = ($this->send_before ?
$this->send_before
: null
);
1019 if (count($errors)) {
1022 $field_sets = array();
1023 foreach ($fields as $key => $value) {
1024 $field_sets[] = XDB
::format($key . ' = {?}', $value);
1026 XDB
::execute('UPDATE newsletter_issues
1027 SET ' . implode(', ', $field_sets) . '
1030 if (XDB
::affectedRows()) {
1033 $errors[] = self
::ERROR_SQL_SAVE
;
1038 /** Get an article by number
1039 * @p $aid Article ID (among articles of the issue)
1040 * @return A NLArticle object, or null if there is no article by that number
1042 public function getArt($aid)
1044 $this->fetchArticles();
1046 foreach ($this->arts
as $category => $artlist) {
1047 if (isset($artlist[$aid])) {
1048 return $artlist[$aid];
1055 * @p $a A reference to a NLArticle object (will be modified once saved)
1057 public function saveArticle($a)
1059 $this->fetchArticles();
1061 // Prevent cid to be 0 (use NULL instead)
1062 $a->cid
= ($a->cid
== 0) ? null
: $a->cid
;
1064 // Article already exists in DB
1065 XDB
::execute('UPDATE newsletter_art
1066 SET cid = {?}, pos = {?}, title = {?}, body = {?}, append = {?}
1067 WHERE id = {?} AND aid = {?}',
1068 $a->cid
, $a->pos
, $a->title
, $a->body
, $a->append
, $this->id
, $a->aid
);
1071 XDB
::startTransaction();
1072 list($aid, $pos) = XDB
::fetchOneRow('SELECT MAX(aid) AS aid, MAX(pos) AS pos
1073 FROM newsletter_art AS a
1077 $a->pos
= ($a->pos ?
$a->pos
: ++
$pos);
1078 XDB
::execute('INSERT INTO newsletter_art (id, aid, cid, pos, title, body, append)
1079 VALUES ({?}, {?}, {?}, {?}, {?}, {?}, {?})',
1080 $this->id
, $a->aid
, $a->cid
, $a->pos
,
1081 $a->title
, $a->body
, $a->append
);
1084 // Update local ID of article
1085 $this->arts
[$a->aid
] = $a;
1088 /** Delete an article by its ID
1089 * @p $aid ID of the article to delete
1091 public function delArticle($aid)
1093 $this->fetchArticles();
1095 XDB
::execute('DELETE FROM newsletter_art WHERE id={?} AND aid={?}', $this->id
, $aid);
1096 foreach ($this->arts
as $key=>$art) {
1097 unset($this->arts
[$key][$aid]);
1104 /** Retrieve the title of this issue
1105 * @p $mail Whether we want the normal title or the email subject
1106 * @return Title of the issue
1108 public function title($mail = false
)
1110 return $mail ?
$this->title_mail
: $this->title
;
1113 /** Retrieve the head of this issue
1114 * @p $user User for <dear> customization (may be null: no customization)
1115 * @p $type Either 'text' or 'html'
1116 * @return Formatted head of the issue.
1118 public function head($user = null
, $type = 'text')
1120 if (is_null($user)) {
1123 $head = $this->head
;
1124 $head = str_replace(array('<cher>', '<prenom>', '<nom>'),
1125 array(($user->isFemale() ?
'Chère' : 'Cher'), $user->displayName(), ''),
1127 return format_text($head, $type, 2, 64);
1131 /** Retrieve the formatted signature of this issue.
1133 public function signature($type = 'text')
1135 return format_text($this->signature
, $type, 2, 64);
1138 /** Get the title of a given category
1139 * @p $cid ID of the category to retrieve
1140 * @return Name of the category
1142 public function category($cid)
1144 return $this->nl
->cats
[$cid];
1147 /** Add required data to the given $page for proper CSS display
1148 * @p $page Smarty object
1149 * @return Either 'true' (if CSS was added to a page) or the raw CSS to add (when $page is null)
1151 public function css($page = null
)
1153 if (!is_null($page)) {
1154 $page->addCssLink($this->nl
->cssFile());
1157 $css = file_get_contents(dirname(__FILE__
) . '/../htdocs/css/' . $this->nl
->cssFile());
1158 return preg_replace('@/\*.*?\*/@us', '', $css);
1162 /** Set up a smarty page for a 'text' mode render of the issue
1163 * @p $page Smarty object (using the $this->nl->tplFile() template)
1164 * @p $user User to use when rendering the template
1166 public function toText($page, $user)
1168 $this->fetchArticles();
1171 $page->assign('prefix', null
);
1172 $page->assign('is_mail', false
);
1173 $page->assign('mail_part', 'text');
1174 $page->assign('user', $user);
1175 $page->assign('hash', null
);
1176 $this->assignData($page);
1179 /** Set up a smarty page for a 'html' mode render of the issue
1180 * @p $page Smarty object (using the $this->nl->tplFile() template)
1181 * @p $user User to use when rendering the template
1183 public function toHtml($page, $user)
1185 $this->fetchArticles();
1188 $page->assign('prefix', $this->nl
->prefix() . '/show/' . $this->id());
1189 $page->assign('is_mail', false
);
1190 $page->assign('mail_part', 'html');
1191 $page->assign('user', $user);
1192 $page->assign('hash', null
);
1193 $this->assignData($page);
1196 /** Set all 'common' data for the page (those which are required for both web and email rendering)
1197 * @p $smarty Smarty object (e.g page) which should be filled
1199 protected function assignData($smarty)
1201 $this->fetchArticles();
1203 $smarty->assign_by_ref('issue', $this);
1204 $smarty->assign_by_ref('nl', $this->nl
);
1210 /** Check whether this issue is empty
1211 * An issue is empty if the email has no title (or the default one), or no articles and an empty head.
1213 public function isEmpty()
1215 return $this->title_mail
== '' ||
$this->title_mail
== 'to be continued' ||
(count($this->arts
) == 0 && strlen($this->head
) == 0);
1218 /** Retrieve the 'Send before' date, in a clean format.
1220 public function getSendBeforeDate()
1222 return strftime('%Y-%m-%d', strtotime($this->send_before
));
1225 /** Retrieve the 'Send before' time (i.e hour), in a clean format.
1227 public function getSendBeforeTime()
1229 return strtotime($this->send_before
);
1232 /** Create a hash based on some additional data
1233 * $line Line-specific data (to prevent two hashes generated at the same time to be the same)
1235 protected static function createHash($line)
1237 $hash = implode(time(), $line) . rand();
1242 /** Send this issue to the given user, reusing an existing hash if provided.
1243 * @p $user User to whom the issue should be mailed
1244 * @p $hash Optional hash to use in the 'unsubscribe' link; if null, another one will be generated.
1246 public function sendTo($user, $hash = null
)
1249 $this->fetchArticles();
1251 if (is_null($hash)) {
1252 $hash = XDB
::fetchOneCell("SELECT hash
1254 WHERE uid = {?} AND nlid = {?}",
1255 $user->id(), $this->nl
->id
);
1257 if (is_null($hash)) {
1258 $hash = self
::createHash(array($user->displayName(), $user->fullName(),
1259 $user->isFemale(), $user->isEmailFormatHtml(),
1260 rand(), "X.org rulez"));
1261 XDB
::execute("UPDATE newsletter_ins as ni
1263 WHERE ni.uid = {?} AND ni.nlid = {?}",
1264 $hash, $user->id(), $this->nl
->id
);
1267 $mailer = new PlMailer($this->nl
->tplFile());
1268 $this->assignData($mailer);
1269 $mailer->assign('is_mail', true
);
1270 $mailer->assign('user', $user);
1271 $mailer->assign('prefix', null
);
1272 $mailer->assign('hash', $hash);
1273 if (!empty($this->reply_to
)) {
1274 $mailer->addHeader('Reply-To', $this->reply_to
);
1277 // Add mailing list headers
1278 // Note: "Precedence: bulk" is known to cause issues on some clients
1279 $mailer->addHeader('Precedence', 'list');
1281 $mailer->addHeader('List-Id', $this->nl
->group
.
1282 ' <' . $this->nl
->group
. '.newsletter.' . $globals->mail
->domain
. '>');
1284 $listurl = $this->nl
->fullUrl();
1285 $mailer->addHeader('List-Unsubscribe', '<' . $listurl . '/out/nohash/' . $this->id
. '>');
1286 $mailer->addHeader('List-Subscribe', '<' . $listurl. '/in/nohash/' . $this->id
. '>');
1287 $mailer->addHeader('List-Archive', '<' . $listurl . '>');
1288 $mailer->addHeader('List-Help', '<' . $listurl . '>');
1289 $mailer->addHeader('List-Owner', '<mailto:support@' . $globals->mail
->domain
. '>');
1291 $mailer->sendTo($user);
1294 /** Select a subset of subscribers which should receive the newsletter.
1295 * NL-Specific selections (not yet received, is subscribed) are done when sending.
1296 * @return A PlFilterCondition.
1298 protected function getRecipientsUFC()
1300 return $this->sufb
->getUFC();
1303 /** Check whether a given user may see this issue.
1304 * @p $user User whose access should be checked
1305 * @return Whether he may access the issue
1307 public function checkUser($user = null
)
1309 if ($user == null
) {
1312 $uf = new UserFilter($this->getRecipientsUFC());
1313 return $uf->checkUser($user);
1316 /** Sent this issue to all valid recipients
1317 * @return Number of issues sent
1319 public function sendToAll()
1321 $this->fetchArticles();
1323 XDB
::execute('UPDATE newsletter_issues
1324 SET state = \'sent\', date=CURDATE()
1328 $ufc = new PFC_And($this->getRecipientsUFC(), new UFC_NLSubscribed($this->nl
->id
, $this->id
), new UFC_HasValidEmail());
1329 $uf = new UserFilter($ufc, array(new UFO_IsAdmin(true
), new UFO_Uid()));
1330 $limit = new PlLimit(self
::BATCH_SIZE
);
1331 $global_sent = array();
1335 $users = $uf->getUsers($limit);
1336 if (count($users) == 0) {
1339 foreach ($users as $user) {
1340 if (array_key_exists($user->id(), $global_sent)) {
1341 Platal
::page()->kill('Sending the same newsletter issue ' . $this->id
. ' to user ' . $user->id() . ' twice, something must be wrong.');
1343 $sent[] = $user->id();
1344 $global_sent[$user->id()] = true
;
1345 $this->sendTo($user, $hash);
1347 XDB
::execute("UPDATE newsletter_ins
1349 WHERE nlid = {?} AND uid IN {?}", $this->id
, $this->nl
->id
, $sent);
1353 return count($global_sent);
1360 // {{{ class NLArticle
1364 // Maximum number of lines per article
1365 const MAX_LINES_PER_ARTICLE
= 8;
1366 const MAX_CHARACTERS_PER_LINE
= 68;
1380 function __construct($title='', $body='', $append='', $aid=-1, $cid=0, $pos=0)
1382 $this->body
= $body;
1383 $this->title
= $title;
1384 $this->append
= $append;
1391 // {{{ function title()
1393 public function title()
1394 { return trim($this->title
); }
1397 // {{{ function body()
1399 public function body()
1400 { return trim($this->body
); }
1403 // {{{ function append()
1405 public function append()
1406 { return trim($this->append
); }
1409 // {{{ function toText()
1411 public function toText($hash = null
, $login = null
)
1413 $title = '*'.$this->title().'*';
1414 $body = MiniWiki
::WikiToText($this->body
, true
);
1415 $app = MiniWiki
::WikiToText($this->append
, false
, 4);
1416 $text = trim("$title\n\n$body\n\n$app")."\n";
1417 if (!is_null($hash) && !is_null($login)) {
1418 $text = str_replace('%HASH%', "$hash/$login", $text);
1420 $text = str_replace('%HASH%', '', $text);
1426 // {{{ function toHtml()
1428 public function toHtml($hash = null
, $login = null
)
1430 $title = "<h2 class='xorg_nl'><a id='art{$this->aid}'></a>".pl_entities($this->title()).'</h2>';
1431 $body = MiniWiki
::WikiToHTML($this->body
);
1432 $app = MiniWiki
::WikiToHTML($this->append
);
1435 $art .= "<div class='art'>\n$body\n";
1437 $art .= "<div class='app'>$app</div>";
1440 if (!is_null($hash) && !is_null($login)) {
1441 $art = str_replace('%HASH%', "$hash/$login", $art);
1443 $art = str_replace('%HASH%', '', $art);
1450 // {{{ function check()
1452 public function check()
1454 $rest = $this->remain();
1456 return $rest['remaining_lines'] >= 0;
1460 // {{{ function remain()
1462 public function remain()
1464 $text = MiniWiki
::WikiToText($this->body
);
1465 $array = explode("\n", wordwrap($text, self
::MAX_CHARACTERS_PER_LINE
));
1467 foreach ($array as $line) {
1468 if (trim($line) != '') {
1474 'remaining_lines' => self
::MAX_LINES_PER_ARTICLE
- $lines_count,
1475 'remaining_characters_for_last_line' => self
::MAX_CHARACTERS_PER_LINE
- strlen($array[count($array) - 1])
1479 // {{{ function parseUrlsFromArticle()
1481 protected function parseUrlsFromArticle()
1483 $email_regex = '([a-z0-9.\-+_\$]+@([\-.+_]?[a-z0-9])+)';
1484 $url_regex = '((https?|ftp)://[a-zA-Z0-9._%#+/?=&~-]+)';
1485 $regex = '{' . $email_regex . '|' . $url_regex . '}i';
1488 $body_matches = array();
1489 if (preg_match_all($regex, $this->body(), $body_matches)) {
1490 $matches = array_merge($matches, $body_matches[0]);
1493 $append_matches = array();
1494 if (preg_match_all($regex, $this->append(), $append_matches)) {
1495 $matches = array_merge($matches, $append_matches[0]);
1502 // {{{ function getLinkIps()
1504 public function getLinkIps(&$blacklist_host_resolution_count)
1506 $matches = $this->parseUrlsFromArticle();
1507 $article_ips = array();
1509 if (!empty($matches)) {
1512 foreach ($matches as $match) {
1513 $host = parse_url($match, PHP_URL_HOST
);
1515 list(, $host) = explode('@', $match);
1518 if ($blacklist_host_resolution_count >= $globals->mail
->blacklist_host_resolution_limit
) {
1522 if (!preg_match('/^(' . str_replace(' ', '|', $globals->mail
->domain_whitelist
) . ')$/i', $host)) {
1523 $article_ips = array_merge($article_ips, array(gethostbyname($host) => $host));
1524 ++
$blacklist_host_resolution_count;
1529 return $article_ips;
1539 function format_text($input, $format, $indent = 0, $width = 68)
1541 if ($format == 'text') {
1542 return MiniWiki
::WikiToText($input, true
, $indent, $width, "title");
1544 return MiniWiki
::WikiToHTML($input, "title");
1547 // function enriched_to_text($input,$html=false,$just=false,$indent=0,$width=68)
1551 // vim:set et sw=4 sts=4 sws=4 enc=utf-8: