diff --git a/src/applications/conpherence/controller/ConpherenceUpdateController.php b/src/applications/conpherence/controller/ConpherenceUpdateController.php index 4fe4ddcb1e..cf193f4419 100644 --- a/src/applications/conpherence/controller/ConpherenceUpdateController.php +++ b/src/applications/conpherence/controller/ConpherenceUpdateController.php @@ -1,209 +1,187 @@ <?php /** * @group conpherence */ final class ConpherenceUpdateController extends ConpherenceController { private $conpherenceID; public function setConpherenceID($conpherence_id) { $this->conpherenceID = $conpherence_id; return $this; } public function getConpherenceID() { return $this->conpherenceID; } public function willProcessRequest(array $data) { $this->setConpherenceID(idx($data, 'id')); } public function processRequest() { $request = $this->getRequest(); $user = $request->getUser(); $conpherence_id = $this->getConpherenceID(); if (!$conpherence_id) { return new Aphront404Response(); } $conpherence = id(new ConpherenceThreadQuery()) ->setViewer($user) ->withIDs(array($conpherence_id)) ->executeOne(); $supported_formats = PhabricatorFile::getTransformableImageFormats(); $updated = false; $error_view = null; $e_file = array(); $errors = array(); if ($request->isFormPost()) { $content_source = PhabricatorContentSource::newForSource( PhabricatorContentSource::SOURCE_WEB, array( 'ip' => $request->getRemoteAddr() )); + $editor = id(new ConpherenceEditor()) + ->setContentSource($content_source) + ->setActor($user); $action = $request->getStr('action'); switch ($action) { case 'message': $message = $request->getStr('text'); - $files = array(); - $file_phids = - PhabricatorMarkupEngine::extractFilePHIDsFromEmbeddedFiles( - array($message) - ); - if ($file_phids) { - $files = id(new PhabricatorFileQuery()) - ->setViewer($user) - ->withPHIDs($file_phids) - ->execute(); - } - $xactions = array(); - if ($files) { - $xactions[] = id(new ConpherenceTransaction()) - ->setTransactionType(ConpherenceTransactionType::TYPE_FILES) - ->setNewValue(array('+' => mpull($files, 'getPHID'))); - } - $xactions[] = id(new ConpherenceTransaction()) - ->setTransactionType(PhabricatorTransactions::TYPE_COMMENT) - ->attachComment( - id(new ConpherenceTransactionComment()) - ->setContent($message) - ->setConpherencePHID($conpherence->getPHID()) - ); + $xactions = $editor->generateTransactionsFromText( + $conpherence, + $message + ); $time = time(); $conpherence->openTransaction(); - $xactions = id(new ConpherenceEditor()) - ->setContentSource($content_source) - ->setActor($user) - ->applyTransactions($conpherence, $xactions); + $xactions = $editor->applyTransactions($conpherence, $xactions); $last_xaction = end($xactions); $xaction_phid = $last_xaction->getPHID(); $behind = ConpherenceParticipationStatus::BEHIND; $up_to_date = ConpherenceParticipationStatus::UP_TO_DATE; $participants = $conpherence->getParticipants(); foreach ($participants as $phid => $participant) { if ($phid != $user->getPHID()) { if ($participant->getParticipationStatus() != $behind) { $participant->setBehindTransactionPHID($xaction_phid); } $participant->setParticipationStatus($behind); $participant->setDateTouched($time); } else { $participant->setParticipationStatus($up_to_date); $participant->setDateTouched($time); } $participant->save(); } $updated = $conpherence->saveTransaction(); break; case 'metadata': $xactions = array(); $images = $request->getArr('image'); if ($images) { // just take the first one $file_phid = reset($images); $file = id(new PhabricatorFileQuery()) ->setViewer($user) ->withPHIDs(array($file_phid)) ->executeOne(); $okay = $file->isTransformableImage(); if ($okay) { $xformer = new PhabricatorImageTransformer(); $xformed = $xformer->executeThumbTransform( $file, $x = 50, $y = 50); $image_phid = $xformed->getPHID(); $xactions[] = id(new ConpherenceTransaction()) ->setTransactionType(ConpherenceTransactionType::TYPE_PICTURE) ->setNewValue($image_phid); } else { $e_file[] = $file; $errors[] = pht('This server only supports these image formats: %s.', implode(', ', $supported_formats)); } } $title = $request->getStr('title'); if ($title != $conpherence->getTitle()) { $xactions[] = id(new ConpherenceTransaction()) ->setTransactionType(ConpherenceTransactionType::TYPE_TITLE) ->setNewValue($title); } if ($xactions) { $conpherence->openTransaction(); - $xactions = id(new ConpherenceEditor()) - ->setContentSource($content_source) - ->setActor($user) + $xactions = $editor ->setContinueOnNoEffect(true) ->applyTransactions($conpherence, $xactions); $updated = $conpherence->saveTransaction(); } else if (empty($errors)) { $errors[] = pht( 'That was a non-update. Try cancel.' ); } break; default: throw new Exception('Unknown action: '.$action); break; } } if ($updated) { return id(new AphrontRedirectResponse())->setURI( $this->getApplicationURI($conpherence_id.'/') ); } if ($errors) { $error_view = id(new AphrontErrorView()) ->setTitle(pht('Errors editing conpherence.')) ->setInsideDialogue(true) ->setErrors($errors); } $form = id(new AphrontFormLayoutView()) ->appendChild( id(new AphrontFormTextControl()) ->setLabel(pht('Title')) ->setName('title') ->setValue($conpherence->getTitle()) ) ->appendChild( id(new AphrontFormMarkupControl()) ->setLabel(pht('Image')) ->setValue(phutil_render_tag( 'img', array( 'src' => $conpherence->loadImageURI(), )) ) ) ->appendChild( id(new AphrontFormDragAndDropUploadControl()) ->setLabel(pht('Change Image')) ->setName('image') ->setValue($e_file) ->setCaption('Supported formats: '.implode(', ', $supported_formats)) ); require_celerity_resource('conpherence-update-css'); return id(new AphrontDialogResponse()) ->setDialog( id(new AphrontDialogView()) ->setUser($user) ->setTitle(pht('Update Conpherence')) ->setWidth(AphrontDialogView::WIDTH_FORM) ->setSubmitURI($this->getApplicationURI('update/'.$conpherence_id.'/')) ->addHiddenInput('action', 'metadata') ->appendChild($error_view) ->appendChild($form) ->addSubmitButton() ->addCancelButton($this->getApplicationURI($conpherence->getID().'/')) ); } } diff --git a/src/applications/conpherence/editor/ConpherenceEditor.php b/src/applications/conpherence/editor/ConpherenceEditor.php index f993696ed0..9304e0f14e 100644 --- a/src/applications/conpherence/editor/ConpherenceEditor.php +++ b/src/applications/conpherence/editor/ConpherenceEditor.php @@ -1,181 +1,217 @@ <?php /** * @group conpherence */ final class ConpherenceEditor extends PhabricatorApplicationTransactionEditor { + public function generateTransactionsFromText( + ConpherenceThread $conpherence, + $text) { + + $files = array(); + $file_phids = + PhabricatorMarkupEngine::extractFilePHIDsFromEmbeddedFiles( + array($text) + ); + // Since these are extracted from text, we might be re-including the + // same file -- e.g. a mock under discussion. Filter files we + // already have. + $existing_file_phids = $conpherence->getFilePHIDs(); + $file_phids = array_diff($file_phids, $existing_file_phids); + if ($file_phids) { + $files = id(new PhabricatorFileQuery()) + ->setViewer($this->getActor()) + ->withPHIDs($file_phids) + ->execute(); + } + $xactions = array(); + if ($files) { + $xactions[] = id(new ConpherenceTransaction()) + ->setTransactionType(ConpherenceTransactionType::TYPE_FILES) + ->setNewValue(array('+' => mpull($files, 'getPHID'))); + } + $xactions[] = id(new ConpherenceTransaction()) + ->setTransactionType(PhabricatorTransactions::TYPE_COMMENT) + ->attachComment( + id(new ConpherenceTransactionComment()) + ->setContent($text) + ->setConpherencePHID($conpherence->getPHID()) + ); + return $xactions; + } + public function getTransactionTypes() { $types = parent::getTransactionTypes(); $types[] = PhabricatorTransactions::TYPE_COMMENT; $types[] = ConpherenceTransactionType::TYPE_TITLE; $types[] = ConpherenceTransactionType::TYPE_PICTURE; $types[] = ConpherenceTransactionType::TYPE_PARTICIPANTS; $types[] = ConpherenceTransactionType::TYPE_FILES; return $types; } protected function getCustomTransactionOldValue( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case ConpherenceTransactionType::TYPE_TITLE: return $object->getTitle(); case ConpherenceTransactionType::TYPE_PICTURE: return $object->getImagePHID(); case ConpherenceTransactionType::TYPE_PARTICIPANTS: return $object->getParticipantPHIDs(); case ConpherenceTransactionType::TYPE_FILES: return $object->getFilePHIDs(); } } protected function getCustomTransactionNewValue( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case ConpherenceTransactionType::TYPE_TITLE: case ConpherenceTransactionType::TYPE_PICTURE: return $xaction->getNewValue(); case ConpherenceTransactionType::TYPE_PARTICIPANTS: case ConpherenceTransactionType::TYPE_FILES: return $this->getPHIDTransactionNewValue($xaction); } } protected function applyCustomInternalTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case ConpherenceTransactionType::TYPE_TITLE: $object->setTitle($xaction->getNewValue()); break; case ConpherenceTransactionType::TYPE_PICTURE: $object->setImagePHID($xaction->getNewValue()); break; } } /** * For now this only supports adding more files and participants. */ protected function applyCustomExternalTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case ConpherenceTransactionType::TYPE_FILES: $editor = id(new PhabricatorEdgeEditor()) ->setActor($this->getActor()); $edge_type = PhabricatorEdgeConfig::TYPE_OBJECT_HAS_FILE; foreach ($xaction->getNewValue() as $file_phid) { $editor->addEdge( $object->getPHID(), $edge_type, $file_phid ); } $editor->save(); break; case ConpherenceTransactionType::TYPE_PARTICIPANTS: foreach ($xaction->getNewValue() as $participant) { if ($participant == $this->getActor()->getPHID()) { $status = ConpherenceParticipationStatus::UP_TO_DATE; } else { $status = ConpherenceParticipationStatus::BEHIND; } id(new ConpherenceParticipant()) ->setConpherencePHID($object->getPHID()) ->setParticipantPHID($participant) ->setParticipationStatus($status) ->setDateTouched(time()) ->setBehindTransactionPHID($xaction->getPHID()) ->save(); } break; } } protected function mergeTransactions( PhabricatorApplicationTransaction $u, PhabricatorApplicationTransaction $v) { $type = $u->getTransactionType(); switch ($type) { case ConpherenceTransactionType::TYPE_TITLE: case ConpherenceTransactionType::TYPE_PICTURE: return $v; case ConpherenceTransactionType::TYPE_FILES: case ConpherenceTransactionType::TYPE_PARTICIPANTS: return $this->mergePHIDTransactions($u, $v); } return parent::mergeTransactions($u, $v); } protected function supportsMail() { return true; } protected function buildReplyHandler(PhabricatorLiskDAO $object) { return id(new ConpherenceReplyHandler()) ->setActor($this->getActor()) ->setMailReceiver($object); } protected function buildMailTemplate(PhabricatorLiskDAO $object) { $id = $object->getID(); $title = $object->getTitle(); if (!$title) { $title = pht( '%s sent you a message.', $this->getActor()->getUserName() ); } $phid = $object->getPHID(); return id(new PhabricatorMetaMTAMail()) ->setSubject("E{$id}: {$title}") ->addHeader('Thread-Topic', "E{$id}: {$phid}"); } protected function getMailTo(PhabricatorLiskDAO $object) { $participants = $object->getParticipants(); return array_keys($participants); } protected function getMailCC(PhabricatorLiskDAO $object) { return array(); } protected function buildMailBody( PhabricatorLiskDAO $object, array $xactions) { $body = parent::buildMailBody($object, $xactions); $body->addTextSection( pht('CONPHERENCE DETAIL'), PhabricatorEnv::getProductionURI('/conpherence/'.$object->getID().'/')); return $body; } protected function getMailSubjectPrefix() { return PhabricatorEnv::getEnvConfig('metamta.conpherence.subject-prefix'); } protected function supportsFeed() { return false; } protected function supportsSearch() { return false; } } diff --git a/src/applications/conpherence/mail/ConpherenceReplyHandler.php b/src/applications/conpherence/mail/ConpherenceReplyHandler.php index 60aa0e152e..f7de36cb25 100644 --- a/src/applications/conpherence/mail/ConpherenceReplyHandler.php +++ b/src/applications/conpherence/mail/ConpherenceReplyHandler.php @@ -1,85 +1,80 @@ <?php /** * @group conpherence */ final class ConpherenceReplyHandler extends PhabricatorMailReplyHandler { public function validateMailReceiver($mail_receiver) { if (!($mail_receiver instanceof ConpherenceThread)) { throw new Exception("Mail receiver is not a ConpherenceThread!"); } } public function getPrivateReplyHandlerEmailAddress( PhabricatorObjectHandle $handle) { return $this->getDefaultPrivateReplyHandlerEmailAddress($handle, 'E'); } public function getPublicReplyHandlerEmailAddress() { return $this->getDefaultPublicReplyHandlerEmailAddress('E'); } public function getReplyHandlerInstructions() { if ($this->supportsReplies()) { return pht('Reply to comment and attach files.'); } else { return null; } } protected function receiveEmail(PhabricatorMetaMTAReceivedMail $mail) { $conpherence = $this->getMailReceiver(); $user = $this->getActor(); if (!$conpherence->getPHID()) { $conpherence ->attachParticipants(array()) ->attachFilePHIDs(array()); } else { $edge_type = PhabricatorEdgeConfig::TYPE_OBJECT_HAS_FILE; $file_phids = PhabricatorEdgeQuery::loadDestinationPHIDs( $conpherence->getPHID(), $edge_type ); $conpherence->attachFilePHIDs($file_phids); $participants = id(new ConpherenceParticipant()) ->loadAllWhere('conpherencePHID = %s', $conpherence->getPHID()); $participants = mpull($participants, null, 'getParticipantPHID'); $conpherence->attachParticipants($participants); } - $body = $mail->getCleanTextBody(); - $body = trim($body); - $file_phids = $mail->getAttachments(); - $body = $this->enhanceBodyWithAttachments($body, $file_phids); - - $xactions = array(); - if ($file_phids) { - $xactions[] = id(new ConpherenceTransaction()) - ->setTransactionType(ConpherenceTransactionType::TYPE_FILES) - ->setNewValue(array('+' => $file_phids)); - } - $xactions[] = id(new ConpherenceTransaction()) - ->setTransactionType(PhabricatorTransactions::TYPE_COMMENT) - ->attachComment( - id(new ConpherenceTransactionComment()) - ->setContent($body) - ->setConpherencePHID($conpherence->getPHID()) - ); - $content_source = PhabricatorContentSource::newForSource( PhabricatorContentSource::SOURCE_EMAIL, array( 'id' => $mail->getID(), )); $editor = id(new ConpherenceEditor()) ->setActor($user) ->setContentSource($content_source) - ->setParentMessageID($mail->getMessageID()) - ->applyTransactions($conpherence, $xactions); + ->setParentMessageID($mail->getMessageID()); + + $body = $mail->getCleanTextBody(); + $body = trim($body); + $file_phids = $mail->getAttachments(); + $body = $this->enhanceBodyWithAttachments( + $body, + $file_phids, + '{F%d}' + ); + $xactions = $editor->generateTransactionsFromText( + $conpherence, + $body + ); + + $editor->applyTransactions($conpherence, $xactions); return null; } } diff --git a/src/applications/metamta/replyhandler/PhabricatorMailReplyHandler.php b/src/applications/metamta/replyhandler/PhabricatorMailReplyHandler.php index d6b862e23d..51784d7b96 100644 --- a/src/applications/metamta/replyhandler/PhabricatorMailReplyHandler.php +++ b/src/applications/metamta/replyhandler/PhabricatorMailReplyHandler.php @@ -1,320 +1,322 @@ <?php abstract class PhabricatorMailReplyHandler { private $mailReceiver; private $actor; private $excludePHIDs = array(); final public function setMailReceiver($mail_receiver) { $this->validateMailReceiver($mail_receiver); $this->mailReceiver = $mail_receiver; return $this; } final public function getMailReceiver() { return $this->mailReceiver; } final public function setActor(PhabricatorUser $actor) { $this->actor = $actor; return $this; } final public function getActor() { return $this->actor; } final public function setExcludeMailRecipientPHIDs(array $exclude) { $this->excludePHIDs = $exclude; return $this; } final public function getExcludeMailRecipientPHIDs() { return $this->excludePHIDs; } abstract public function validateMailReceiver($mail_receiver); abstract public function getPrivateReplyHandlerEmailAddress( PhabricatorObjectHandle $handle); public function getReplyHandlerDomain() { return PhabricatorEnv::getEnvConfig( 'metamta.reply-handler-domain' ); } abstract public function getReplyHandlerInstructions(); abstract protected function receiveEmail( PhabricatorMetaMTAReceivedMail $mail); public function processEmail(PhabricatorMetaMTAReceivedMail $mail) { $error = $this->sanityCheckEmail($mail); if ($error) { if ($this->shouldSendErrorEmail($mail)) { $this->sendErrorEmail($error, $mail); } return null; } return $this->receiveEmail($mail); } private function sanityCheckEmail(PhabricatorMetaMTAReceivedMail $mail) { $body = $mail->getCleanTextBody(); $attachments = $mail->getAttachments(); if (empty($body) && empty($attachments)) { return 'Empty email body. Email should begin with an !action and / or '. 'text to comment. Inline replies and signatures are ignored.'; } return null; } /** * Only send an error email if the user is talking to just Phabricator. We * can assume if there is only one To address it is a Phabricator address * since this code is running and everything. */ private function shouldSendErrorEmail(PhabricatorMetaMTAReceivedMail $mail) { return (count($mail->getToAddresses()) == 1) && (count($mail->getCCAddresses()) == 0); } private function sendErrorEmail($error, PhabricatorMetaMTAReceivedMail $mail) { $template = new PhabricatorMetaMTAMail(); $template->setSubject('Exception: unable to process your mail request'); $template->setBody($this->buildErrorMailBody($error, $mail)); $template->setRelatedPHID($mail->getRelatedPHID()); $phid = $this->getActor()->getPHID(); $tos = array( $phid => PhabricatorObjectHandleData::loadOneHandle($phid) ); $mails = $this->multiplexMail($template, $tos, array()); foreach ($mails as $email) { $email->saveAndSend(); } return true; } private function buildErrorMailBody($error, PhabricatorMetaMTAReceivedMail $mail) { $original_body = $mail->getRawTextBody(); $main_body = <<<EOBODY Your request failed because an error was encoutered while processing it: ERROR: {$error} -- Original Body ------------------------------------------------------------- {$original_body} EOBODY; $body = new PhabricatorMetaMTAMailBody(); $body->addRawSection($main_body); $body->addReplySection($this->getReplyHandlerInstructions()); return $body->render(); } public function supportsPrivateReplies() { return (bool)$this->getReplyHandlerDomain() && !$this->supportsPublicReplies(); } public function supportsPublicReplies() { if (!PhabricatorEnv::getEnvConfig('metamta.public-replies')) { return false; } if (!$this->getReplyHandlerDomain()) { return false; } return (bool)$this->getPublicReplyHandlerEmailAddress(); } final public function supportsReplies() { return $this->supportsPrivateReplies() || $this->supportsPublicReplies(); } public function getPublicReplyHandlerEmailAddress() { return null; } final public function getRecipientsSummary( array $to_handles, array $cc_handles) { assert_instances_of($to_handles, 'PhabricatorObjectHandle'); assert_instances_of($cc_handles, 'PhabricatorObjectHandle'); $body = ''; if (PhabricatorEnv::getEnvConfig('metamta.recipients.show-hints')) { if ($to_handles) { $body .= "To: ".implode(', ', mpull($to_handles, 'getName'))."\n"; } if ($cc_handles) { $body .= "Cc: ".implode(', ', mpull($cc_handles, 'getName'))."\n"; } } return $body; } final public function multiplexMail( PhabricatorMetaMTAMail $mail_template, array $to_handles, array $cc_handles) { assert_instances_of($to_handles, 'PhabricatorObjectHandle'); assert_instances_of($cc_handles, 'PhabricatorObjectHandle'); $result = array(); // If MetaMTA is configured to always multiplex, skip the single-email // case. if (!PhabricatorMetaMTAMail::shouldMultiplexAllMail()) { // If private replies are not supported, simply send one email to all // recipients and CCs. This covers cases where we have no reply handler, // or we have a public reply handler. if (!$this->supportsPrivateReplies()) { $mail = clone $mail_template; $mail->addTos(mpull($to_handles, 'getPHID')); $mail->addCCs(mpull($cc_handles, 'getPHID')); if ($this->supportsPublicReplies()) { $reply_to = $this->getPublicReplyHandlerEmailAddress(); $mail->setReplyTo($reply_to); } $result[] = $mail; return $result; } } $tos = mpull($to_handles, null, 'getPHID'); $ccs = mpull($cc_handles, null, 'getPHID'); // Merge all the recipients together. TODO: We could keep the CCs as real // CCs and send to a "noreply@domain.com" type address, but keep it simple // for now. $recipients = $tos + $ccs; // When multiplexing mail, explicitly include To/Cc information in the // message body and headers. $mail_template = clone $mail_template; $mail_template->addPHIDHeaders('X-Phabricator-To', array_keys($tos)); $mail_template->addPHIDHeaders('X-Phabricator-Cc', array_keys($ccs)); $body = $mail_template->getBody(); $body .= "\n"; $body .= $this->getRecipientsSummary($to_handles, $cc_handles); foreach ($recipients as $phid => $recipient) { $mail = clone $mail_template; if (isset($to_handles[$phid])) { $mail->addTos(array($phid)); } else if (isset($cc_handles[$phid])) { $mail->addCCs(array($phid)); } else { // not good - they should be a to or a cc continue; } $mail->setBody($body); $reply_to = null; if (!$reply_to && $this->supportsPrivateReplies()) { $reply_to = $this->getPrivateReplyHandlerEmailAddress($recipient); } if (!$reply_to && $this->supportsPublicReplies()) { $reply_to = $this->getPublicReplyHandlerEmailAddress(); } if ($reply_to) { $mail->setReplyTo($reply_to); } $result[] = $mail; } return $result; } protected function getDefaultPublicReplyHandlerEmailAddress($prefix) { $receiver = $this->getMailReceiver(); $receiver_id = $receiver->getID(); $domain = $this->getReplyHandlerDomain(); // We compute a hash using the object's own PHID to prevent an attacker // from blindly interacting with objects that they haven't ever received // mail about by just sending to D1@, D2@, etc... $hash = PhabricatorMetaMTAReceivedMail::computeMailHash( $receiver->getMailKey(), $receiver->getPHID()); $address = "{$prefix}{$receiver_id}+public+{$hash}@{$domain}"; return $this->getSingleReplyHandlerPrefix($address); } protected function getSingleReplyHandlerPrefix($address) { $single_handle_prefix = PhabricatorEnv::getEnvConfig( 'metamta.single-reply-handler-prefix'); return ($single_handle_prefix) ? $single_handle_prefix . '+' . $address : $address; } protected function getDefaultPrivateReplyHandlerEmailAddress( PhabricatorObjectHandle $handle, $prefix) { if ($handle->getType() != PhabricatorPHIDConstants::PHID_TYPE_USER) { // You must be a real user to get a private reply handler address. return null; } $receiver = $this->getMailReceiver(); $receiver_id = $receiver->getID(); $user_id = $handle->getAlternateID(); $hash = PhabricatorMetaMTAReceivedMail::computeMailHash( $receiver->getMailKey(), $handle->getPHID()); $domain = $this->getReplyHandlerDomain(); $address = "{$prefix}{$receiver_id}+{$user_id}+{$hash}@{$domain}"; return $this->getSingleReplyHandlerPrefix($address); } - final protected function enhanceBodyWithAttachments($body, - array $attachments) { + final protected function enhanceBodyWithAttachments( + $body, + array $attachments, + $format = '- {F%d, layout=link}') { if (!$attachments) { return $body; } $files = id(new PhabricatorFile()) ->loadAllWhere('phid in (%Ls)', $attachments); // if we have some text then double return before adding our file list if ($body) { $body .= "\n\n"; } foreach ($files as $file) { - $file_str = sprintf('- {F%d, layout=link}', $file->getID()); + $file_str = sprintf($format, $file->getID()); $body .= $file_str."\n"; } return rtrim($body); } }