Envoi de mail

Pb et pistes

Envoyer un mail en texte brut en php semble simple, pourtant après pas mal de recherches et de creusage de méninges je n'ai pas réussi à trouver de système passe partout.

On aurait pourtant pu penser qu'avec mb_send_mail on avait la solution, ou que mb_encode_mimeheader (ou iconv_mime_encode) permettait de s'en sortir, sauf que ça marche pas dans ce cas particulier.

Visiblement les mailers encodent les headers le plus souvent en quoted printable, mais en mode "non gourmand", chaque mot (ou série) étant encodée entre =?UTF-8?Q?…?=, alors que les fcts php ne mettent que deux délimiteurs par lignes. Les deux devraient fonctionner, dixit les rfc, sauf que non…

  • mb_send_mail est une cata car
    • encode en base64 ⇒ spam d'office
    • encode pas le To ni les headers sup (From)
  • iconv_mime_encode pourrait être mieux, mais seulement pour les headers sans adresse mail (car il encode le @), et pour le sujet ça va pas non plus car il veut le nom du header pour l'ajouter, et pas la fct mail (faudrait danc l'enlever après encodage avant de le filer à la fct). Par ailleurs il ajoute des =?UTF-8?Q? au mileu des lignes (en plus du début, curieux…)
  • quoted_printable_encode coupe les lignes à 76 car mais il met pas le =?UTF-8?Q? au début, ça oblige donc à recoller pour redécouper autrement
  • mb_encode_mimeheader est finalement le plus simple, même si je n'arrive pas à encoder correctement les sujet longs accentués pour windows mail avec une adresse @free.

J'ai fait tous mes tests avec postfix 2.7.1 sur debian squeeze en UTF-8.

La classe ci-dessous marche, sauf avec les sujets avec accents si on utilise windows mail sur un serveur qmail (free.fr par exemple).

Exemple de solution (partielle)

/**
 * Une classe pour envoyer des mails en texte brut correctement encodés (avec accents 
 * dans le sujet ou les noms de destinataire / expéditeur).
 * Les destinataires peuvent être des listes (tableaux ou chaînes avec la virgule en séparateur).
 * Chacun pouvant être "nom <adresse>", "<adresse>" ou "adresse".
 * 
 * marche pas si on cumule sujets avec accents, windows mail et adresse @free.fr.
 * Code sous licence GPL
 * @author daniel.caillibaud@sesamath.net
 */
 
class Mail {
 
  /**
   * Le charset utilisé (UTF-8, exception sinon, faudra venir coder une méthode setEncoding
   * et tester de manière approfondie le jour où y'aura besoin d'autre chose)
   * @var string 
   */
  protected $charset;
 
  /**
   * Sujet (encodé $charset quoted-printable)
   * @var string 
   */
  protected $subject;
 
  /**
   * Destinataire(s) (encodés $charset quoted-printable)
   * @var string 
   */
  protected $to;
 
  /**
   * Le message complet ($charset)
   * @var string 
   */
  protected $message;
 
  /**
   * Tous les autres headers (encodés $charset quoted-printable)
   * On utilise une chaîne (et pas un tableau) car la valeur dépend de la clé (le retour chariot éventuel)
   * et que l'on utilise mb_encode_mimeheader qui travaille sur toute la ligne (clé comprise)
   * @var string 
   */
  protected $headers;
 
  /**
   * L'expéditeur par défaut (MAIL_RETURN_PATH ou bounces), utilisé s'il na pas été précisé avant l'envoi
   * @var string 
   */
  protected $default_sender;
 
  /**
   * Pour ne pas ajouter plusieurs fois nos headers par défaut
   * @var boolean 
   */
  protected $default_headers_added = FALSE;
 
  /**
   * Constructeur pour init de $charset, $default_sender et des headers génériques Content-*
   * @throws Exception 
   */
  function __construct() {
    // notre End Of Header Line
    if (!defined('EOHL')) {
      define('EOHL', "\r\n");
    }
    // le charset
    // si on veut autre chose que de l'UTF-8 faudra coder une méthode $this->setEncoding
    // On lance une exception pour être sûr que qqun viendra mettre son nez ici
    if (mb_internal_encoding() != 'UTF-8') {
      throw new Exception("Cette classe Mail ne fonctionne qu'avec un mb_internal_encoding en UTF-8 (ici on a " .$this->charset .')');
    }
    $this->charset = 'utf-8'; // en minuscule
 
    // le from par défaut (utilisé en Return-Path)
    $this->default_sender = (defined('MAIL_RETURN_PATH')) ? MAIL_RETURN_PATH : '';
 
    // Les headers communs à tous nos mails
    $this->headers = '';
    // Return-Path
    if (defined('MAIL_RETURN_PATH')) {
      $this->headers .= 'Return-Path: <' .$this->default_sender .'>' .EOHL; 
    }
    // Les autres headers communs à tous nos mails sont ajoutés dans le send pour qu'ils soient en dernier
  } // __construct
 
  /**
   * Une remplaçante de la fct mail classique de php (ATTENTION, pour les 3 premiers arguments seulement)
   * @param string|array $to        Le ou les destinataire(s) 
   *   Adresse seule ou bien "Nom en UTF-8 <adresse@domaine.tld>", dans un tableau ou 
   *   une chaine à virgules si plusieurs destinataires
   * @param string       $subject   L'objet du mail (UTF-8)
   * @param string       $message   Le message texte à envoyer (UTF-8)
   * @param string       $from      Facultatif, l'intitulé de l'expéditeur (ou intitulé + adresse)
   * @param string       $replyto   Facultatif, l'adresse de réponse (adresse seule ou bien "Nom en UTF-8 <adresse@domaine.tld>")
   * @param array        $args_sup  Les headers supplémentaires, sous la forme array(header_key => header_value)
   * @return boolean                Le résultat de la fct mail de php: TRUE si le mail a été accepté pour livraison, FALSE sinon.
   */
  public static function mail($to, $subject, $message, $from = '', $replyto = '', $args_sup = array()) {
    $mail = new Mail;
    $mail->setTo($to);
    // setSubject avant ou après ne change rien à l'ordre dans lequel la fct mail de php ordonnera les headers au final
    // pourtant mattre le sujet en dernier permettrait d'interpréter les autres headers quand windows mail tronque le sujet
    $mail->setSubject($subject);
    // contenu
    $mail->setMessage($message);
 
    // pour le from éventuel plusieurs cas
    if (!empty($from)) {
      // attention, ça peut être le nom + adresse
      if (strpos($from, '<')) {
        list($from_name,$from_addr) = $mail->extractNameAndAddress($from);
      }
      // l'adresse seule
      elseif (strpos($from, '@')) {
        $from_name = '';
        $from_addr = $from;
      }
      // le nom seul
      else {
        $from_name = str_replace(',', '', $from); // gaffe aux virgules, mb_encode_mimeheader les échappe pas
        $from_addr = '';
      }
      $mail->setFrom($from_name, $from_addr);
    }
 
    // replyto supposé complet ou adresse seule
    if (!empty($replyto)) {
      list($replyto_name, $replyto_addr) = $mail->extractNameAndAddress($replyto);
      $mail->setReplyTo($replyto_name, $replyto_addr);
    }
 
    // et les args Sup
    foreach ($args_sup as $header_key => $header_value) {
      // on liste pas les headers avec destinataire, on regarde juste chaine sans @ ou pas
      if (is_string($header_value) && strpos($header_value)) { // @ en 1er caractère pas géré comme un vrai destinataire 
        $mail->setHeader($header_key, $header_value);
      }
      else {
        $mail->setRecipients($header_key, $header_value);
      }
    }
    // reste à envoyer
    return $mail->send();
  } // mail
 
  /**
   * Affecte le destinataire (wrapper de setRecipients)
   * @param string|array $to Le ou les destinataire, même format que pour setRecipients
   */
  public function setTo($to) {
    $this->setRecipients('To', $to);
  }
 
  /**
   * Affecte un ou des destinataires
   * 
   * On ne vérifie pas la syntaxe de l'adresse mail (car très lourd si on veut le faire correctement, 
   * cf http://www.linuxjournal.com/article/9585?page=0,3)
   * 
   * @param type $header Le type de destinataire (To, Cc, Bcc, Reply-To...)
   * @param type $recipients Le ou les destinataires (chaine ou tableau)
   *   Peut être sous la forme
   *   - une chaine avec une adresse seule
   *   - une chaine avec un seul destinataire "nom <adresse>"
   *   - une liste, sous forme de chaine séparée par des virgules (mélange des deux précédents formats possible)
   *   - une liste en tableau (avec les éléments sous forme "adresse", "nom <adresse>" ou nom => adresse, panachage possible)
   */
  public function setRecipients($header_key, $recipients) {
    // le virer s'il existe déjà (pas gourmand sinon, même pour To)
    $this->removeHeader($header_key);
 
    // si plusieurs destinataires dans une chaine, on en fait un tableau
    if (is_string($recipients) && strpos($recipients, ',')) {
      $recipients = explode(',', $recipients);
    }
 
    // si toujours une chaine, c'est un destinataire unique
    if (is_string($recipients)) {
      // c'est un peu idiot d'e séparer'extraire pour réassembler plus tard
      // mais sinon les <@> sont encodés par mb_encode_mimeheader
      list($name, $address) = $this->extractNameAndAddress($recipients);
      $header_value = $this->headerFormat($header_key, $name, $address);
      if ($header_key == 'To') {
        $this->to = $header_value;
      }
      else {
        $this->headers .= $header_value .EOHL;
      }
    }
 
    // plusieurs destinataires
    elseif (is_array($recipients)) {
      $this->headers .= $this->recipientsArrayToHeader($header_key, $recipients) .EOHL;
    }
 
    // sinon pas normal, log mais sans s'arreter pour ça
    else {
      trigger_error("setRecipients n'a reçu ni chaine ni tableau comme destinataire");
    }
  } // setRecipients
 
  /**
   * Affecte le sujet du mail
   * @param string $subject 
   */
  public function setSubject($subject) {
    $subject = $this->headerFormat('Subject', $subject);
    /* version où on met tout sur une ligne * /
    $subject = str_replace('?=' .EOHL .'=?utf-8?Q?', '', $subject);
    /* version avec _ à la place des =20 * /
    $subject = str_replace('=20', '_', $subject);
    /* */
    $this->subject = $subject;
  } // setSubject
 
  /**
   * Affecte le message
   * @param string $message 
   */
  public function setMessage($message) {
    // $this->message = quoted_printable_encode($message); // à mettre avec Content-Transfer-Encoding: quoted-printable
    $this->message = $message;
  } // setMessage
 
  /**
   * Affecte l'expéditeur
   * @param string $name
   * @param string $address
   */
  public function setFrom($name, $address) {
    if ($address == '') {
      if (empty($this->default_sender)) {
        return FALSE;
      }
      $address = $this->default_sender;
    }
    $this->removeHeader('From');
    $this->headers .= $this->headerFormat('From', $name, $address) .EOHL;
  } // setFrom
 
  /**
   * Affecte le header Reply-To
   * @param string $name
   * @param string $address
   */
  public function setReplyTo($name, $address) {
    if ($address == '') {
      $address = $this->default_sender;
    }
    if (empty($name) || !is_string($name)) {
      $this->setRecipients('Reply-To', $address);
    }
    else {
      // version liste (ici un seul destinataire)
      $this->setRecipients('Reply-To', array($name => $address));
    }
  } // setReplyTo
 
  /**
   * Ajoute un header quelconque (et l'encode correctement). Pour un header avec adresse(s) 
   * mail, utiliser setRecipients à la place
   * @param string $name   Le nom du header (Content-Type par ex)
   * @param string $value  La valeur du header
   */
  public function setHeader($header_key, $header_value) {
    $this->removeHeader($header_key);
    $this->headers .= $this->headerFormat($header_key, $header_value) .EOHL;
  } // setHeader
 
  /**
   * Vire un header s'il le trouve
   * @param string $header_key Le nom du header
   */
  public function removeHeader($header_key) {
    $start = strpos($this->headers, $header_key .': ');
    if ($start !== FALSE) {
      // y'en avait un, on le vire
      $end = strpos($this->headers, EOHL, $start +1); // $this->headers se termine par EOHL donc on est sûr d'en trouver un
      $this->headers = substr($this->headers, 0, $start) .substr($this->headers, $end + strlen(EOHL));
    }
  } // removeHeader
 
  /**
   * Envoie le mail
   * @return boolean Le résultat de la fct mail de php (TRUE si le mail a été accepté pour envoi, FALSE sinon).
   */
  public function send() {
    return mail($this->to, $this->subject, $this->message, $this->getHeaders());
  } // send
 
  /**
   * Récupère les headers du mail courant et ajoute le from s'il n'y est pas
   * @return string Les headers
   */
  protected function getHeaders() {
    // on ajoute le From s'il a pas été mis
    if (strpos($this->headers, 'From: ') === FALSE && !empty($this->default_sender)) {
      $this->headers .= 'From: ' .$this->default_sender .EOHL;
    }
    // et nos headers par défaut
    $this->addDefaultHeaders();
    return $this->headers;
  } // getHeaders
 
 
  /**
   * Formate un header d'après un tableau de destinataires (pour To, Cc, Bcc, Reply-To...)
   * @param string $header_key Le nom header (To, Cc ou Bcc)
   * @param array $recipients les destinataires, chacun sous la forme 
   *   "adresse" ou nom => adresse ou "nom <adresse>" (à éviter au profit du précédent, car on doit séparer nom et adresse)
   * @return string Le header formaté quoted-printable (et préfixé si c'est pas To)
   */
  protected function recipientsArrayToHeader($header_key, $recipients) {
    if (!is_array($recipients)) {
      throw new Exception('Paramètre invalide');
    }
    $header = ''; // la string du header (clé: valeur)
    foreach($recipients as $name => $address) {
      // name pas forcément la clé, peut être dans la valeur de l'élément
      if (is_numeric($name)) {
        list($name, $address) = $this->extractNameAndAddress($address);
      }
      if (strpos($address, '@')) {
        if ($header == '') {
          // c'est le 1er, faut $header_key en début (sauf To mais headerFormat le gère)
          $header = $this->headerFormat($header_key, $name, $address);
        }
        else {
          // on cherche pas à savoir si ça peut rentrer sur la dernière ligne, on va à la ligne
          // on met 'To' en $header pour pas le récupérer en retour
          // (si on avait mis xx il aurait fallu virer les "xx: " du début du retour
          $header .= ',' .EOHL .' ' .$this->headerFormat('To', $name, $address);
        }
      } 
      else { // pas une adresse mail
        // on peut ne récupérer qu'un fragment de nom, par ex s'il contenait une virgule 
        // il peut avoir été coupé en 2 par un explode, on ignore (mais on loggue) car une
        // portion de string sans adresse serait envoyé en ajoutant @hostname
        trigger_error("destinataire sans adresse ($recipient), probablement une virgule dans un nom, ignoré");
      }
    }
    // On se préoccupe pas du préfixe, headerFormat l'a fait au 1er passage
    return $header;
  } // recipientsArrayToHeader
 
  /**
   * Sépare adresse mail et nom d'une chaine de la forme "nom <adresse>"
   * @param string $string
   * @param array  [$name,$address]
   */
  protected function extractNameAndAddress($string) {
    $start = strpos($string, '<');
    if ($start === FALSE) {
      $name = '';
      $addr = $string;
    } else {
      // on vire tout séparateur éventuel dans le nom
      $name = trim(str_replace(',', '', substr($string, 0, $start)));
      $addr = substr($string, $start, strpos($string, '>'));
    }
    return array($name, $addr);
  } // extractNameAndAddress
 
  /**
   * Encode un header et retourne la chaîne correspondante préfixée (sauf To et Subject)
   *
   * @param string $header_key Le nom du header (To, Subject, From, Reply-To, etc.)
   * @param string $str La valeur du header (la chaîne à encoder comme le nom d'un destinataire ou le sujet)
   * @param string $adress L'éventuelle adresse mail, à passer séparément pour ne pas encoder <@>
   * @return string Le header formaté et préfixé (sauf To et Subject) sans retour chariot de fin
   */
  protected function headerFormat($header_key, $str, $address = '') {
    // On ajoute toujours le header en préfixe, sauf To ou Subject 
    // car la fct mail de php ne veut que la valeur (sans "clé: " en début de ligne)
    if ($header_key == 'To' || $header_key == 'Subject') { 
      // faut un offset pour rester sous les 74 quand la fct mail de php aura ajouté le header 
      // dans le source envoyé à sendmail (+2 pour le ": " ajouté)
      $enc_str = mb_encode_mimeheader($str, $this->charset, "Q", EOHL, strlen($header_key) +2);
    }
    else { // nom du header au début
      if ($str != '') {
        // si on laissait mb_encode_mimeheader il virerait l'espace de fin et 
        // l'adresse se retrouverait ensuite collée au ":"
        $enc_str = mb_encode_mimeheader($header_key .': ' .$str, $this->charset, "Q", EOHL);
      }
      else { 
        $enc_str = $header_key .': ';
      }
    }
 
    // La chaine est correctement encodée (et préfixée si besoin), on regarde l'adresse mail
    if ($address != '') {
      if (!empty($str)) {
        // faut ajouter une espace
        $enc_str .= ' ';
        // Et les chevrons dans l'adresse (s'ils sont pas déjà là)
        if (strpos($address, '<') === FALSE) {
          $address = '<' .$address .'>';
        }
      }
      // et on concatène tout ça, mais faut regarder si on a la place sur la dernière ligne
      // on peut pas utiliser le modulo car les lignes font pas forcément toutes la même taille
      $lines = explode(EOHL, $enc_str);
      $reste = 74 - strlen(array_pop($lines));
      // ça rentre ?
      if ($reste < strlen($address)) {
        // pas la place, faut sauter une ligne et ajouter une espace
        $enc_str .= EOHL .' ';
      }
      $enc_str .= $address;
    }
 
    // La fonction mb_encode_mimeheader() revoie une chaine contenant "UTF-8" qui pose peut-être pb
    // (tous les courrielleurs et lib étudiés mettent utf-8)
    // Et lui donner utf-8 comme paramètre n'y change rien, d'où ce traitement à posteriori avec str_replace().
    return str_replace('UTF-8', 'utf-8', $enc_str);
    // return str_replace( mb_internal_encoding() , strtolower(mb_internal_encoding()) , $enc_str ); // mettre ça le jour ou c'est plus utf-8 only
  } // headerFormat (function headerFormatOld avec quoted_printable_encode à la place de mb_encode_mimeheader dans la rev129)
 
 
  /**
   * Ajoute les headers Mime-Version, Content-type et Content-Transfer-Encoding
   * Ne le fera qu'une fois même avec plusieurs appels.
   * @staticvar boolean $done Pour ne pas les ajouter plusieurs fois
   */
  protected function addDefaultHeaders() {
    if (!$this->default_headers_added) {
      // Les headers communs à tous nos mails, ajoutés dans le send si pas fait avant 
      $this->headers .= 'Mime-Version: 1.0' .EOHL;
      $this->headers .= 'Content-type: text/plain; charset=' .$this->charset .EOHL;
      $this->headers .= 'Content-Transfer-Encoding: 8bit'.EOHL;
      //$this->headers .= 'Content-Transfer-Encoding: quoted-printable' .EOHL;
      $this->default_headers_added = TRUE;
    }
  }
 
}

Exemples

print(mb_encode_mimeheader("un truc évident mais pénible à la longue","UTF-8","Q"));'
=>
un truc =?UTF-8?Q?=C3=83=C2=A9vident=20mais=20p=C3=83=C2=A9nible=20=C3=83?=
 =?UTF-8?Q?=C2=A0=20la=20longue?=

print(iconv_mime_encode("From", "un truc évident mais pénible à la longue",
   array("scheme" => "Q", "input-charset" => "UTF-8", "output-charset" => "UTF-8")));
=>
From: =?UTF-8?Q?un=20truc=20=C3=A9vident=20ma?==?UTF-8?Q?is=20p?=
 =?UTF-8?Q?=C3=A9nible=20=C3=A0=20la=20longue?=

print(quoted_printable_encode("From: un truc évident mais pénible à la longue"));
=>
From: un truc =C3=A9vident mais p=C3=A9nible =C3=A0 la longue

Et ensuite, y'a deux écoles

un truc =?UTF-8?Q?=C3=A9vident?=

ou

?UTF-8?Q?un=20truc=20=C3=A9vident?=

Tous les mailers observés utilisent le 1er pour le sujet (idem mb_encode_mimeheader) et le 2e (comme iconv_mime_encode) pour les adresses comme

From: ?ISO-8859-1?Q?un=20truc=20=E9vident?= <uneadresse@example.com>
 
php/mail.txt · Dernière modification: 17/10/2012 01:14 par daniel