Merge branch 'xorg/maint'
[platal.git] / include / newsletter.inc.php
index 0ee1423..313d98d 100644 (file)
@@ -1,6 +1,6 @@
 <?php
 /***************************************************************************
- *  Copyright (C) 2003-2011 Polytechnique.org                              *
+ *  Copyright (C) 2003-2014 Polytechnique.org                              *
  *  http://opensource.polytechnique.org/                                   *
  *                                                                         *
  *  This program is free software; you can redistribute it and/or modify   *
@@ -37,15 +37,20 @@ class NewsLetter
     public $cats;  // List of all categories for this NL
     public $criteria;  // PlFlagSet of allowed filters for recipient selection
 
-    protected $custom_css = false;
-
     // Base name to use instead of the group short name for NLs without a custom CSS
     const FORMAT_DEFAULT_GROUP = 'default';
 
     // Diminutif of X.net groups with a specific NL view
     const GROUP_XORG = 'Polytechnique.org';
+    const GROUP_COMMUNITY = 'Annonces';
     const GROUP_AX = 'AX';
     const GROUP_EP = 'Ecole';
+    const GROUP_FX = 'FX';
+
+    // Searches on mutiple fields
+    const SEARCH_ALL = 'all';
+    const SEARCH_TITLE = 'title';
+
 
     // {{{ Constructor, NewsLetter retrieval (forGroup, getAll)
 
@@ -53,7 +58,7 @@ class NewsLetter
     {
         // Load NL data
         $res = XDB::query('SELECT  nls.group_id, g.diminutif AS group_name,
-                                   nls.name AS nl_name, nls.custom_css, nls.criteria
+                                   nls.name AS nl_name, nls.criteria
                              FROM  newsletters AS nls
                         LEFT JOIN  groups AS g ON (nls.group_id = g.id)
                             WHERE  nls.id = {?}',
@@ -67,7 +72,6 @@ class NewsLetter
         $this->group_id = $data['group_id'];
         $this->group = $data['group_name'];
         $this->name = $data['nl_name'];
-        $this->custom_css = $data['custom_css'];
         $this->criteria = new PlFlagSet($data['criteria']);
 
         // Load the categories
@@ -100,15 +104,13 @@ class NewsLetter
     /** Retrieve all newsletters
      * @return An array of $id => NewsLetter objects
      */
-    public static function getAll()
+    public static function getAll($sort = 'id', $order = 'ASC')
     {
-        $res = XDB::query('SELECT  id
-                             FROM  newsletters');
-        $nls = array();
-        foreach ($res->fetchColumn() as $id) {
-            $nls[$id] = new NewsLetter($id);
-        }
-        return $nls;
+        $res = XDB::fetchAllAssoc('SELECT  n.id, g.nom AS group_name, n.name, n.criteria, g.diminutif AS group_link
+                                     FROM  newsletters AS n
+                               INNER JOIN  groups      AS g ON (n.group_id = g.id)
+                                 ORDER BY  ' . $sort . ' ' . $order);
+        return $res;
     }
 
     // }}}
@@ -251,6 +253,63 @@ class NewsLetter
         }
     }
 
+    /** Returns a list of either issues or articles corresponding to the search.
+     * @p $search The searched pattern.
+     * @p $field The fields where to search, if none given, search in all possible fields.
+     * @return The list of object found.
+     */
+    public function issueSearch($search, $field, $user)
+    {
+        $search = XDB::formatWildcards(XDB::WILDCARD_CONTAINS, $search);
+        if ($field == self::SEARCH_ALL) {
+            $where = '(title ' . $search . ' OR mail_title ' . $search . ' OR head ' . $search . ' OR signature ' . $search . ')';
+        } elseif ($field == self::SEARCH_TITLE) {
+            $where = '(title ' . $search . ' OR mail_title ' . $search . ')';
+        } else {
+            $where = $field . $search;
+        }
+        $list = XDB::fetchColumn('SELECT  DISTINCT(id)
+                                    FROM  newsletter_issues
+                                   WHERE  nlid = {?} AND state = \'sent\' AND ' . $where . '
+                                ORDER BY  date DESC',
+                                 $this->id);
+
+        $issues = array();
+        foreach ($list as $id) {
+            $issue = new NLIssue($id, $this, false);
+            if ($issue->checkUser($user)) {
+                $issues[] = $issue;
+            }
+        }
+        return $issues;
+    }
+
+    public function articleSearch($search, $field, $user)
+    {
+        $search = XDB::formatWildcards(XDB::WILDCARD_CONTAINS, $search);
+        if ($field == self::SEARCH_ALL) {
+            $where = '(a.title ' . $search . ' OR a.body ' . $search . ' OR a.append ' . $search . ')';
+        } else {
+            $where = 'a.' . $field . $search;
+        }
+        $list = XDB::fetchAllAssoc('SELECT  i.short_name, a.aid, i.id, a.title
+                                      FROM  newsletter_art    AS a
+                                INNER JOIN  newsletter_issues AS i ON (a.id = i.id)
+                                     WHERE  i.nlid = {?} AND i.state = \'sent\' AND ' . $where . '
+                                  GROUP BY  a.id, a.aid
+                                  ORDER BY  i.date DESC, a.aid',
+                                   $this->id);
+
+        $articles = array();
+        foreach ($list as $item) {
+            $issue = new NLIssue($item['id'], $this, false);
+            if ($issue->checkUser($user)) {
+                $articles[] = $item;
+            }
+        }
+        return $articles;
+    }
+
     // }}}
     // {{{ Subscription related function
 
@@ -259,7 +318,7 @@ class NewsLetter
      * @p $hash True if the uid is actually a hash.
      * @return True if the user was successfully unsubscribed.
      */
-    public function unsubscribe($uid = null, $hash = false)
+    public function unsubscribe($issue_id = null, $uid = null, $hash = false)
     {
         if (is_null($uid) && $hash) {
             // Unable to unsubscribe from an empty hash
@@ -280,6 +339,12 @@ class NewsLetter
         XDB::execute('DELETE FROM  newsletter_ins
                             WHERE  nlid = {?} AND uid = {?}',
                             $this->id, $user);
+        if (!is_null($issue_id)) {
+            XDB::execute('UPDATE  newsletter_issues
+                             SET  unsubscribe = unsubscribe + 1
+                           WHERE  id = {?}',
+                         $id);
+        }
         return true;
     }
 
@@ -298,6 +363,21 @@ class NewsLetter
         }
     }
 
+    /** Subscribe a batch of users to a newsletter.
+     * This skips 'maySubscribe' test.
+     *
+     * @p $user_ids Array of user IDs to subscribe to the newsletter.
+     */
+    public function bulkSubscribe($user_ids)
+    {
+        // TODO: use a 'bulkMaySubscribe'.
+        XDB::execute('INSERT IGNORE INTO  newsletter_ins (nlid, uid, last, hash)
+                                  SELECT  {?}, a.uid, NULL, NULL
+                                    FROM  accounts AS a
+                                   WHERE  a.uid IN {?}',
+                     $this->id, $user_ids);
+    }
+
     /** Retrieve subscription state of a user
      * @p $user Target user; if null, use current user.
      * @return Boolean: true if the user has subscribed to the NL.
@@ -317,11 +397,32 @@ class NewsLetter
     /** Get the count of subscribers to the NL.
      * @return Number of subscribers.
      */
-    public function subscriberCount()
+    public function subscriberCount($lost = null, $sex = null, $grade = null, $first_promo = null, $last_promo = null)
     {
-        return XDB::fetchOneCell('SELECT  COUNT(uid)
-                                    FROM  newsletter_ins
-                                   WHERE  nlid = {?}', $this->id);
+        $cond = new PFC_And(new UFC_NLSubscribed($this->id));
+        if (!is_null($sex)) {
+            $cond->addChild(new UFC_Sex($sex));
+        }
+        if (!is_null($grade)) {
+            $cond->addChild(new UFC_Promo('>=', $grade, $first_promo));
+            $cond->addChild(new UFC_Promo('<=', $grade, $last_promo));
+        }
+        if (!($lost === null)) {
+            if ($lost === true) {
+                $cond->addChild(new PFC_Not(new UFC_HasEmailRedirect()));
+            } else {
+                $cond->addChild(new UFC_HasEmailRedirect());
+            }
+        }
+        $uf = new UserFilter($cond);
+        return $uf->getTotalCount();
+    }
+
+    /** Get the count of subscribers with non valid redirection.
+     */
+    public function lostSubscriberCount($sex = null)
+    {
+        return $this->subscriberCount(true, $sex);
     }
 
     /** Get the number of subscribers to the NL whose last received mailing was $last.
@@ -399,8 +500,11 @@ class NewsLetter
      */
     public function maySubmit($user = null)
     {
-        // Submission of new articles is only enabled for the X.org NL (and forbidden when viewing issues on X.net)
-        return ($this->group == self::GROUP_XORG && !isset($GLOBALS['IS_XNET_SITE']));
+        // Submission of new articles is only enabled for the X.org NL and the
+        // community letter (and forbidden when viewing issues on X.net)
+        return (
+            ($this->group == self::GROUP_XORG || $this->group == self::GROUP_COMMUNITY)
+            && !isset($GLOBALS['IS_XNET_SITE']));
     }
 
     // }}}
@@ -410,7 +514,7 @@ class NewsLetter
      */
     public function cssFile()
     {
-        if ($this->custom_css) {
+        if ($this->hasCustomCss()) {
             $base = $this->group;
         } else {
             $base = self::FORMAT_DEFAULT_GROUP;
@@ -422,7 +526,7 @@ class NewsLetter
      */
     public function tplFile()
     {
-        if ($this->custom_css) {
+        if ($this->hasCustomCss()) {
             $base = $this->group;
         } else {
             $base = self::FORMAT_DEFAULT_GROUP;
@@ -433,18 +537,26 @@ class NewsLetter
     /** Get the prefix leading to the page for this NL
      * Only X.org / AX / X groups may be seen on X.org.
      */
-    public function prefix($enforce_xnet=true)
+    public function prefix($enforce_xnet=true, $with_group=true)
     {
         if (!empty($GLOBALS['IS_XNET_SITE'])) {
-            return $this->group . '/nl';
+            if ($with_group) {
+                return $this->group . '/nl';
+            } else {
+                return 'nl';
+            }
         }
         switch ($this->group) {
         case self::GROUP_XORG:
             return 'nl';
+        case self::GROUP_COMMUNITY:
+            return 'comletter';
         case self::GROUP_AX:
             return 'ax';
         case self::GROUP_EP:
             return 'epletter';
+        case self::GROUP_FX:
+            return 'fxletter';
         default:
             // Don't display groups NLs on X.org
             assert(!$enforce_xnet);
@@ -453,24 +565,91 @@ class NewsLetter
 
     /** Get the prefix to use for all 'admin' pages of this NL.
      */
-    public function adminPrefix($enforce_xnet=true)
+    public function adminPrefix($enforce_xnet=true, $with_group=true)
     {
         if (!empty($GLOBALS['IS_XNET_SITE'])) {
-            return $this->group . '/admin/nl';
+            if ($with_group) {
+                return $this->group . '/admin/nl';
+            } else {
+                return 'admin/nl';
+            }
         }
         switch ($this->group) {
         case self::GROUP_XORG:
             return 'admin/newsletter';
+        case self::GROUP_COMMUNITY:
+            return 'comletter/admin';
         case self::GROUP_AX:
             return 'ax/admin';
         case self::GROUP_EP:
             return 'epletter/admin';
+        case self::GROUP_FX:
+            return 'fxletter/admin';
         default:
             // Don't display groups NLs on X.org
             assert(!$enforce_xnet);
         }
     }
 
+    /** Get the prefix to use for all 'stat' pages of this NL.
+     */
+    public function statPrefix($enforce_xnet = true, $with_group = true)
+    {
+        if (!empty($GLOBALS['IS_XNET_SITE'])) {
+            if ($with_group) {
+                return $this->group . '/stat/nl';
+            } else {
+                return 'stat/nl';
+            }
+        }
+        switch ($this->group) {
+        case self::GROUP_XORG:
+            return 'stat/newsletter';
+        case self::GROUP_COMMUNITY:
+            return 'comletter/stat';
+        case self::GROUP_AX:
+            return 'ax/stat';
+        case self::GROUP_EP:
+            return 'epletter/stat';
+        case self::GROUP_FX:
+            return 'fxletter/stat';
+        default:
+            // Don't display groups NLs on X.org
+            assert(!$enforce_xnet);
+        }
+    }
+
+    /** Get a full URL to a newsletter
+     */
+    public function fullUrl()
+    {
+        switch ($this->group) {
+        case self::GROUP_XORG:
+            return 'https://www.polytechnique.org/nl';
+        case self::GROUP_COMMUNITY:
+            return 'https://www.polytechnique.org/comletter';
+        case self::GROUP_AX:
+            return 'https://www.polytechnique.org/ax';
+        case self::GROUP_EP:
+            return 'https://www.polytechnique.org/epletter';
+        case self::GROUP_FX:
+            return 'https://www.polytechnique.org/fxletter';
+        default:
+            return 'http://www.polytechnique.net/' . $this->group . '/nl';
+        }
+    }
+
+    /** Get links for nl pages.
+     */
+    public function adminLinks()
+    {
+        return array(
+            'index' => array('link' => $this->prefix(), 'title' => 'Archives'),
+            'admin' => array('link' => $this->adminPrefix(), 'title' => 'Administrer'),
+            'stats' => array('link' => $this->statPrefix(), 'title' => 'Statistiques')
+        );
+    }
+
     /** Hack used to remove "admin" links on X.org page on X.net
      * The 'admin' links are enabled for all pages, except for X.org when accessing NL through X.net
      */
@@ -488,7 +667,30 @@ class NewsLetter
 
     public function hasCustomCss()
     {
-        return $this->custom_css;
+        switch ($this->group) {
+          case self::GROUP_XORG:
+          case self::GROUP_COMMUNITY:
+          case self::GROUP_AX:
+          case self::GROUP_EP:
+          case self::GROUP_FX:
+            return true;
+          default:
+            return false;
+        }
+    }
+
+    public function canSyncWithGroup()
+    {
+        switch ($this->group) {
+          case self::GROUP_XORG:
+          case self::GROUP_COMMUNITY:
+          case self::GROUP_AX:
+          case self::GROUP_EP:
+          case self::GROUP_FX:
+            return false;
+          default:
+            return true;
+        }
     }
 
     // }}}
@@ -520,6 +722,7 @@ class NLIssue
     public $send_before;  // Date at which issue should be sent
     public $head;  // Foreword of the issue (or body for letters with no articles)
     public $signature;  // Signature of the letter
+    public $reply_to;  // Adress to reply to the message (can be empty)
     public $arts = array();  // Articles of the issue
 
     const BATCH_SIZE = 60;  // Number of emails to send every minute.
@@ -544,7 +747,7 @@ class NLIssue
     {
         // Load this issue
         $res = XDB::query('SELECT  nlid, short_name, date, send_before, state, sufb_json,
-                                   title, mail_title, head, signature
+                                   title, mail_title, head, signature, reply_to
                              FROM  newsletter_issues
                             WHERE  id = {?}',
                           $id);
@@ -558,6 +761,7 @@ class NLIssue
             $this->nl = new NewsLetter($issue['nlid']);
         }
         $this->id = $id;
+        $this->nlid        = $issue['nlid'];
         $this->shortname   = $issue['short_name'];
         $this->date        = $issue['date'];
         $this->send_before = $issue['send_before'];
@@ -566,6 +770,7 @@ class NLIssue
         $this->title_mail  = $issue['mail_title'];
         $this->head        = $issue['head'];
         $this->signature   = $issue['signature'];
+        $this->reply_to    = $issue['reply_to'];
         $this->sufb        = $this->importJSonStoredUFB($issue['sufb_json']);
 
         if ($fetch_articles) {
@@ -756,7 +961,11 @@ class NLIssue
     public function last()
     {
         if (is_null($this->id_last)) {
-            $this->id_last = $this->nl->getIssue('last')->id;
+            try {
+                $this->id_last = $this->nl->getIssue('last')->id;
+            } catch (MailNotFound $e) {
+                $this->id_last = null;
+            }
         }
         return $this->id_last;
     }
@@ -764,8 +973,10 @@ class NLIssue
     // }}}
     // {{{ Edition, articles
 
+    const ERROR_INVALID_REPLY_TO = 'invalid_reply_to';
     const ERROR_INVALID_SHORTNAME = 'invalid_shortname';
     const ERROR_INVALID_UFC = 'invalid_ufc';
+    const ERROR_TOO_LONG_UFC = 'too_long_ufc';
     const ERROR_SQL_SAVE = 'sql_error';
 
     /** Save the global properties of this NL issue (title&co).
@@ -782,6 +993,12 @@ class NLIssue
             'signature' => $this->signature,
         );
 
+        if (!empty($this->reply_to) && !isvalid_email($this->reply_to)) {
+            $errors[] = self::ERROR_INVALID_REPLY_TO ;
+        } else {
+            $fields['reply_to'] = $this->reply_to;
+        }
+
         if ($this->isEditable()) {
             $fields['date'] = $this->date;
             if (!preg_match('/^[-a-z0-9]+$/i', $this->shortname) || is_numeric($this->shortname)) {
@@ -791,6 +1008,11 @@ class NLIssue
             }
             if ($this->sufb->isValid() || $this->sufb->isEmpty()) {
                 $fields['sufb_json'] = json_encode($this->sufb->export()->dict());
+                // If sufb_json is too long to be store, we do not store a truncated json and notify the user.
+                // The limit is LONGTEXT's one, ie 2^32 = 4294967296.
+                if (strlen($fields['sufb_json']) > 4294967295) {
+                    $errors[] = self::ERROR_TOO_LONG_UFC;
+                }
             } else {
                 $errors[] = self::ERROR_INVALID_UFC;
             }
@@ -1029,6 +1251,14 @@ class NLIssue
      */
     public function sendTo($user, $hash = null)
     {
+        global $globals;
+
+        // Don't send email to users without an address
+        // Note: this would never happen when using sendToAll
+        if (!$user->bestEmail()) {
+            return;
+        }
+
         $this->fetchArticles();
 
         if (is_null($hash)) {
@@ -1053,6 +1283,24 @@ class NLIssue
         $mailer->assign('user', $user);
         $mailer->assign('prefix',  null);
         $mailer->assign('hash',    $hash);
+        if (!empty($this->reply_to)) {
+            $mailer->addHeader('Reply-To', $this->reply_to);
+        }
+
+        // Add mailing list headers
+        // Note: "Precedence: bulk" is known to cause issues on some clients
+        $mailer->addHeader('Precedence', 'list');
+        // RFC 2919 header
+        $mailer->addHeader('List-Id', $this->nl->group .
+            ' <' . $this->nl->group . '.newsletter.' . $globals->mail->domain . '>');
+        // RFC 2369 headers
+        $listurl = $this->nl->fullUrl();
+        $mailer->addHeader('List-Unsubscribe', '<' . $listurl . '/out/nohash/' . $this->id . '>');
+        $mailer->addHeader('List-Subscribe', '<' . $listurl. '/in/nohash/' . $this->id . '>');
+        $mailer->addHeader('List-Archive', '<' . $listurl . '>');
+        $mailer->addHeader('List-Help', '<' . $listurl . '>');
+        $mailer->addHeader('List-Owner', '<mailto:support@' . $globals->mail->domain . '>');
+
         $mailer->sendTo($user);
     }
 
@@ -1091,20 +1339,23 @@ class NLIssue
                        $this->id);
 
         $ufc = new PFC_And($this->getRecipientsUFC(), new UFC_NLSubscribed($this->nl->id, $this->id), new UFC_HasValidEmail());
-        $emailsCount = 0;
-        $uf = new UserFilter($ufc, array(new UFO_IsAdmin(), new UFO_Uid()));
+        $uf = new UserFilter($ufc, array(new UFO_IsAdmin(true), new UFO_Uid()));
         $limit = new PlLimit(self::BATCH_SIZE);
+        $global_sent = array();
 
         while (true) {
             $sent = array();
             $users = $uf->getUsers($limit);
             if (count($users) == 0) {
-                return $emailsCount;
+                break;
             }
             foreach ($users as $user) {
+                if (array_key_exists($user->id(), $global_sent)) {
+                    Platal::page()->kill('Sending the same newsletter issue ' . $this->id . ' to user ' . $user->id() . ' twice, something must be wrong.');
+                }
                 $sent[] = $user->id();
+                $global_sent[$user->id()] = true;
                 $this->sendTo($user, $hash);
-                ++$emailsCount;
             }
             XDB::execute("UPDATE  newsletter_ins
                              SET  last = {?}
@@ -1112,7 +1363,7 @@ class NLIssue
 
             sleep(60);
         }
-        return $emailsCount;
+        return count($global_sent);
     }
 
     // }}}
@@ -1310,5 +1561,5 @@ function format_text($input, $format, $indent = 0, $width = 68)
 
 // }}}
 
-// vim:set et sw=4 sts=4 sws=4 enc=utf-8:
+// vim:set et sw=4 sts=4 sws=4 fenc=utf-8:
 ?>