Merge pull request #505 from wurstchristoph/drafts

save and open drafts
This commit is contained in:
Jan-Christoph Borchardt 2015-05-05 06:48:17 +02:00
Родитель 0a640bb4df e97e87480a
Коммит ce22cfd351
11 изменённых файлов: 460 добавлений и 103 удалений

Просмотреть файл

@ -13,6 +13,7 @@ $app->registerRoutes($this,
['name' => 'page#index', 'url' => '/', 'verb' => 'GET'],
['name' => 'page#compose', 'url' => '/compose', 'verb' => 'GET'],
['name' => 'accounts#send', 'url' => '/accounts/{accountId}/send', 'verb' => 'POST'],
['name' => 'accounts#draft', 'url' => '/accounts/{accountId}/draft', 'verb' => 'POST'],
['name' => 'accounts#autoComplete', 'url' => '/accounts/autoComplete', 'verb' => 'GET'],
[
'name' => 'messages#downloadAttachment',

Просмотреть файл

@ -43,13 +43,13 @@ var Mail = {
icon: icon
}
);
notification.onclick = function() {
notification.onclick = function(x) {
Mail.UI.loadMessages(accountId, folderId, false);
window.focus();
};
setTimeout(function () {
notification.close();
}, 10000);
}, 5000);
}, checkForNotifications: function() {
_.each(Mail.State.accounts, function (a) {
var localAccount = Mail.State.folderView.collection.get(a.accountId);
@ -482,12 +482,60 @@ var Mail = {
);
},
openMessage: function (messageId) {
openComposer: function(data) {
$('#mail_new_message').prop('disabled', true);
if (Mail.State.composeView === null) {
// setup sendmail view
Mail.State.composeView = new views.SendMail({
el: $('#mail-message'),
aliases: Mail.State.accounts,
data: data
});
Mail.State.composeView.sentCallback = function () {};
} else {
Mail.State.composeView.data = data;
}
if (data && data.hasHtmlBody) {
Mail.UI.showError(t('mail', 'Opening HTML drafts is not supported yet.'));
}
Mail.State.composeView.attachments.reset();
Mail.State.composeView.render();
// focus 'to' field automatically on clicking New message button
$('#to').focus();
Mail.UI.setMessageActive(null);
},
loadDraft: function(messageId) {
var storage = $.localStorage;
var draftId = 'draft'
+ '.' + Mail.State.currentAccountId.toString()
+ '.' + Mail.State.currentFolderId.toString()
+ '.' + messageId.toString();
if (storage.isSet(draftId)) {
return storage.get(draftId);
} else {
return null;
}
},
openMessage: function(messageId) {
// Do not reload email when clicking same again
if (Mail.State.currentMessageId === messageId) {
return;
}
// check if message is a draft
var accountId = Mail.State.currentAccountId;
var account = Mail.State.folderView.collection.findWhere({id: accountId});
var draftsFolder = account.attributes.specialFolders.drafts;
var draft = draftsFolder === Mail.State.currentFolderId;
// close email first
// Check if message is open
if (Mail.State.currentMessageId !== null) {
@ -508,6 +556,68 @@ var Mail = {
$('#mail_new_message').prop('disabled', false);
$('#new-message').hide();
var self = this;
var loadMessageSuccess = function (data) {
// load local storage draft
var draft = self.loadDraft(messageId);
if (draft) {
data.replyCc = draft.cc;
data.replyBcc = draft.bcc;
data.replyBody = draft.body;
}
// Render the message body
var source = $("#mail-message-template").html();
var template = Handlebars.compile(source);
var html = template(data);
mailBody
.html(html)
.removeClass('icon-loading');
Mail.State.messageView.setMessageFlag(messageId, 'unseen', false);
// HTML mail rendering
$('iframe').load(function () {
// Expand height to not have two scrollbars
$(this).height($(this).contents().find('html').height() + 20);
// Fix styling
$(this).contents().find('body').css({
'margin': '0',
'font-weight': 'normal',
'font-size': '.8em',
'line-height': '1.6em',
'font-family': "'Open Sans', Frutiger, Calibri, 'Myriad Pro', Myriad, sans-serif",
'color': '#000'
});
// Fix font when different font is forced
$(this).contents().find('font').prop({
'face': 'Open Sans',
'color': '#000'
});
$(this).contents().find('.moz-text-flowed').css({
'font-family': 'inherit',
'font-size': 'inherit'
});
// Expand height again after rendering to account for new size
$(this).height($(this).contents().find('html').height() + 20);
// Grey out previous replies
$(this).contents().find('blockquote').css({
'-ms-filter': '"progid:DXImageTransform.Microsoft.Alpha(Opacity=50)"',
'filter': 'alpha(opacity=50)',
'opacity': '.5'
});
// Remove spinner when loading finished
$('iframe').parent().removeClass('icon-loading');
});
$('textarea').autosize({append: '"\n\n"'});
};
var loadDraftSuccess = function(data) {
self.openComposer(data);
};
$.ajax(
OC.generateUrl('apps/mail/accounts/{accountId}/folders/{folderId}/messages/{messageId}',
{
@ -518,53 +628,11 @@ var Mail = {
data: {},
type: 'GET',
success: function (data) {
// Render the message body
var source = $("#mail-message-template").html();
var template = Handlebars.compile(source);
var html = template(data);
mailBody
.html(html)
.removeClass('icon-loading');
Mail.State.messageView.setMessageFlag(messageId, 'unseen', false);
// HTML mail rendering
$('iframe').load(function () {
// Expand height to not have two scrollbars
$(this).height($(this).contents().find('html').height() + 20);
// Fix styling
$(this).contents().find('body').css({
'margin': '0',
'font-weight': 'normal',
'font-size': '.8em',
'line-height': '1.6em',
'font-family': "'Open Sans', Frutiger, Calibri, 'Myriad Pro', Myriad, sans-serif",
'color': '#000'
});
// Fix font when different font is forced
$(this).contents().find('font').prop({
'face': 'Open Sans',
'color': '#000'
});
$(this).contents().find('.moz-text-flowed').css({
'font-family': 'inherit',
'font-size': 'inherit'
});
// Expand height again after rendering to account for new size
$(this).height($(this).contents().find('html').height() + 20);
// Grey out previous replies
$(this).contents().find('blockquote').css({
'-ms-filter': '"progid:DXImageTransform.Microsoft.Alpha(Opacity=50)"',
'filter': 'alpha(opacity=50)',
'opacity': '.5'
});
// Remove spinner when loading finished
$('iframe').parent().removeClass('icon-loading');
});
$('textarea').autosize({append: '"\n\n"'});
if (draft) {
loadDraftSuccess(data);
} else {
loadMessageSuccess(data);
}
},
error: function () {
Mail.UI.showError(t('mail', 'Error while loading the selected message.'));
@ -776,29 +844,7 @@ $(document).ready(function () {
});
// new mail message button handling
$(document).on('click', '#mail_new_message', function () {
$('#mail_new_message').prop('disabled', true);
if (Mail.State.composeView === null) {
// setup sendmail view
Mail.State.composeView = new views.SendMail({
el: $('#mail-message'),
aliases: Mail.State.accounts
});
Mail.State.composeView.sentCallback = function () {
};
}
Mail.State.composeView.attachments.reset();
Mail.State.composeView.render();
// focus 'to' field automatically on clicking New message button
$('#to').focus();
Mail.UI.setMessageActive(null);
});
$(document).on('click', '#mail_new_message', Mail.UI.openComposer);
// disable send/reply buttons unless recipient and either subject or message body is filled
$(document).on('change input paste keyup', '#to', Mail.UI.toggleSendButton);

Просмотреть файл

@ -1,4 +1,4 @@
/* global Mail */
/* global Mail, OC */
$(function () {
function split(val) {
@ -53,6 +53,32 @@ $(function () {
}
});
function getReplyMessage() {
var message = {};
var replyMessageBody = $('.reply-message-body');
var to = $('.reply-message-fields #to');
var cc = $('.reply-message-fields #cc');
message.body = replyMessageBody.val();
message.to = to.val();
message.cc = cc.val();
return message;
}
function saveReplyLocally() {
if (Mail.State.currentMessageId === null) {
// new message
return;
}
var storage = $.localStorage;
storage.set('draft'
+ '.' + Mail.State.currentAccountId.toString()
+ '.' + Mail.State.currentFolderId.toString()
+ '.' + Mail.State.currentMessageId.toString(),
getReplyMessage());
}
function sendReply() {
//
// TODO:
@ -116,6 +142,8 @@ $(function () {
}
}
});
$(document).on('keyup', '.reply-message-body, #to, #cc', saveReplyLocally);
$(document).on('click', '.reply-message-send', sendReply);

Просмотреть файл

@ -111,6 +111,15 @@ views.Message = Backbone.Marionette.ItemView.extend({
data: {},
type:'DELETE',
success: function () {
// delete local storage draft
var storage = $.localStorage;
var draftId = 'draft'
+ '.' + Mail.State.currentAccountId.toString()
+ '.' + Mail.State.currentFolderId.toString()
+ '.' + thisModel.id;
if (storage.isSet(draftId)) {
storage.remove(draftId);
}
},
error: function() {
Mail.UI.showError(t('mail', 'Error while deleting message.'));

Просмотреть файл

@ -1,4 +1,4 @@
/* global Backbone, Handlebars, models */
/* global Backbone, Handlebars, models, OC, Mail */
var views = views || {};
@ -11,16 +11,32 @@ views.SendMail = Backbone.View.extend({
aliases: null,
currentAccountId: null,
data: null,
draftIntervalIMAP: 1500,
draftIntervalLocal: 100,
draftTimerIMAP: null,
draftTimerLocal: null,
draftUID: null,
events: {
"click #new-message-send" : "sendMail",
"click #new-message-draft" : "saveDraft",
"keypress #new-message-body" : "handleKeyPress",
"keyup #new-message-body": "handleKeyUp",
"keyup #to": "handleKeyUp",
"keyup #cc": "handleKeyUp",
"keyup #bcc": "handleKeyUp",
"keyup #subject": "handleKeyUp",
"click .mail_account" : "changeAlias"
},
initialize: function(options) {
this.attachments = new models.Attachments();
this.aliases = options.aliases;
if (options.data) {
this.data = options.data;
this.draftUID = options.data.id;
}
this.el = options.el;
this.currentAccountId = this.aliases[0].accountId;
},
@ -30,16 +46,50 @@ views.SendMail = Backbone.View.extend({
},
handleKeyPress: function(event) {
var key = event.keyCode || event.which;
var sendBtnState = $('#new-message-send').attr('disabled');
// check for ctrl+enter
if (event.keyCode === 13 && event.ctrlKey) {
var sendBtnState = $('#new-message-send').attr('disabled');
if (key === 13 && event.ctrlKey) {
if (sendBtnState === undefined) {
this.sendMail();
}
}
return true;
},
handleKeyUp: function() {
clearTimeout(this.draftTimerIMAP);
clearTimeout(this.draftIntervalLocal);
var self = this;
this.draftTimerIMAP = setTimeout(function() {
self.saveDraft();
}, this.draftIntervalIMAP);
this.draftTimerLocal = setTimeout(function() {
self.saveDraftLocally();
}, this.draftIntervalLocal);
},
getMessage: function() {
var message = {};
var newMessageBody = $('#new-message-body');
var to = $('#to');
var cc = $('#cc');
var bcc = $('#bcc');
var subject = $('#subject');
message.body = newMessageBody.val();
message.to = to.val();
message.cc = cc.val();
message.bcc = bcc.val();
message.subject = subject.val();
message.attachments = this.attachments.toJSON();
return message;
},
sendMail: function() {
clearTimeout(this.draftTimerIMAP);
//
// TODO:
// - input validation
@ -66,18 +116,20 @@ views.SendMail = Backbone.View.extend({
newMessageSend.prop('disabled', true);
newMessageSend.val(t('mail', 'Sending …'));
var message = this.getMessage();
var self = this;
// send the mail
$.ajax({
url:OC.generateUrl('/apps/mail/accounts/{accountId}/send', {accountId: this.currentAccountId}),
type: 'POST',
data:{
'to': to.val(),
'cc': cc.val(),
'bcc': bcc.val(),
'subject': subject.val(),
'body':newMessageBody.val(),
'attachments': self.attachments.toJSON()
'to': message.to,
'cc': message.cc,
'bcc': message.bcc,
'subject': message.subject,
'body': message.body,
'attachments': message.attachments,
'draftUID' : this.draftUID
},
success:function () {
OC.Notification.showTemporary(t('mail', 'Message sent!'));
@ -95,6 +147,11 @@ views.SendMail = Backbone.View.extend({
$('#subject').val('');
$('#new-message-body').val('');
self.attachments.reset();
if (self.draftUID !== null) {
// the sent message was a draft
Mail.State.messageView.collection.remove({id: self.draftUID});
self.draftUID = null;
}
},
error: function (jqXHR) {
OC.Notification.showTemporary(jqXHR.responseJSON.message);
@ -117,10 +174,81 @@ views.SendMail = Backbone.View.extend({
return false;
},
saveDraftLocally: function() {
var storage = $.localStorage;
storage.set("draft", "default", this.getMessage());
},
saveDraft: function() {
clearTimeout(this.draftTimerIMAP);
//
// TODO:
// - input validation
// - feedback on success
// - undo lie - very important
//
var message = this.getMessage();
var self = this;
// send the mail
$.ajax({
url:OC.generateUrl('/apps/mail/accounts/{accountId}/draft', {accountId: this.currentAccountId}),
beforeSend:function () {
OC.msg.startAction('#new-message-msg', "");
},
type: 'POST',
data: {
'to': message.to,
'cc': message.cc,
'bcc': message.bcc,
'subject': message.subject,
'body': message.body,
'uid': self.draftUID
},
success: function (data) {
if (self.draftUID !== null) {
// update UID in message list
var message = Mail.State.messageView.collection.findWhere({id: self.draftUID});
if (message) {
message.set({id: data.uid});
Mail.State.messageView.collection.set([message], {remove: false});
}
}
self.draftUID = data.uid;
OC.msg.finishedAction('#new-message-msg', {
status: 'success',
data: {
message: t('mail', 'Draft saved!')
}
});
},
error: function (jqXHR) {
OC.msg.finishedAction('#new-message-msg', {
status: 'error',
data: {
message: jqXHR.responseJSON.message
}
});
}
});
return false;
},
render: function() {
var source = $("#new-message-template").html();
var template = Handlebars.compile(source);
var html = template({aliases: this.aliases});
var data = {
aliases: this.aliases
};
// draft data
if (this.data) {
data.to = this.data.toEmail;
data.subject = this.data.subject;
data.message = this.data.body;
}
var html = template(data);
this.$el.html(html);

Просмотреть файл

@ -266,6 +266,26 @@ class Account {
}
return $specialFoldersIds;
}
/**
* Get the "drafts" mailbox
*
* @return Mailbox The best candidate for the "drafts" inbox
*/
public function getDraftsFolder() {
// check for existense
$draftsFolder = $this->getSpecialFolder('drafts', true);
if (count($draftsFolder) === 0) {
// drafts folder does not exist - let's create one
$conn = $this->getImapConnection();
// TODO: also search for translated drafts mailboxes
$conn->createMailbox('Drafts', array(
'special_use' => array('drafts'),
));
return $this->guessBestMailBox($this->listMailboxes('Drafts'));
}
return $draftsFolder[0];
}
/**
* Get the "sent mail" mailbox
@ -327,6 +347,18 @@ class Account {
array('message' => $messageId, 'mailbox' => $sourceFolderId));
}
/**
*
* @param int $messageId
*/
public function deleteDraft($messageId) {
$draftsFolder = $this->getDraftsFolder();
$IDs = new \Horde_Imap_Client_Ids($messageId);
$draftsMailBox = new \Horde_Imap_Client_Mailbox($draftsFolder->getFolderId(), true);
$this->getImapConnection()->expunge($draftsMailBox);
}
/**
* Get 'best' mailbox guess
*

Просмотреть файл

@ -23,6 +23,9 @@
namespace OCA\Mail\Controller;
use Horde_Imap_Client;
use Horde_Mime_Headers_Date;
use Horde_Mime_Mail;
use Horde_Mime_Part;
use Horde_Mail_Rfc822_Address;
use OCA\Mail\Account;
use OCA\Mail\Db\MailAccount;
@ -141,8 +144,6 @@ class AccountsController extends Controller {
/**
* @NoAdminRequired
* @param $accountId
* @return JSONResponse
*/
public function destroy($accountId) {
try {
@ -229,6 +230,7 @@ class AccountsController extends Controller {
$to = $this->params('to');
$cc = $this->params('cc');
$bcc = $this->params('bcc');
$draftUID = $this->params('draftUID');
$dbAccount = $this->mapper->find($this->currentUserId, $accountId);
$account = new Account($dbAccount);
@ -271,7 +273,7 @@ class AccountsController extends Controller {
$headers['To'] = $to;
// build mime body
$mail = new \Horde_Mime_Mail();
$mail = new Horde_Mime_Mail();
$mail->addHeaders($headers);
$mail->setBody($body);
@ -307,7 +309,19 @@ class AccountsController extends Controller {
// save the message in the sent folder
$sentFolder = $account->getSentFolder();
$raw = stream_get_contents($mail->getRaw());
$sentFolder->saveMessage($raw, [Horde_Imap_Client::FLAG_SEEN]);
$sentFolder->saveMessage($raw, [
Horde_Imap_Client::FLAG_SEEN
]);
// delete draft message
if (!is_null($draftUID)) {
$draftsFolder = $account->getDraftsFolder();
$folderId = $draftsFolder->getFolderId();
$this->logger->debug("deleting sent draft <$draftUID> in folder <$folderId>");
$draftsFolder->setMessageFlag($draftUID, \Horde_Imap_Client::FLAG_DELETED, true);
$account->deleteDraft($draftUID);
$this->logger->debug("sent draft <$draftUID> deleted");
}
} catch (\Horde_Exception $ex) {
$this->logger->error('Sending mail failed: ' . $ex->getMessage());
return new JSONResponse(
@ -319,6 +333,86 @@ class AccountsController extends Controller {
return new JSONResponse();
}
/**
* @NoAdminRequired
*
* @param int $accountId
* @return JSONResponse
*/
public function draft($accountId) {
$subject = $this->params('subject');
$body = $this->params('body');
$to = $this->params('to');
$cc = $this->params('cc');
$bcc = $this->params('bcc');
$uid = $this->params('uid');
if (!is_null($uid)) {
$this->logger->info("Saving a new draft in accout <$accountId>");
} else {
$this->logger->info("Updating draft <$uid> in account <$accountId>");
}
$dbAccount = $this->mapper->find($this->currentUserId, $accountId);
$account = new Account($dbAccount);
// get sender data
$headers = array();
$from = new Horde_Mail_Rfc822_Address($account->getEMailAddress());
$from->personal = $account->getName();
$headers['From']= $from;
$headers['Subject'] = $subject;
if (trim($cc) !== '') {
$headers['Cc'] = trim($cc);
}
if (trim($bcc) !== '') {
$headers['Bcc'] = trim($bcc);
}
$headers['To'] = $to;
$headers['Date'] = Horde_Mime_Headers_Date::create();
// build mime body
$mail = new Horde_Mime_Mail();
$mail->addHeaders($headers);
$bodyPart = new Horde_Mime_Part();
$bodyPart->appendContents($body, [
'encoding' => \Horde_Mime_Part::ENCODE_8BIT
]);
$mail->setBasePart($bodyPart);
// create transport and save message
try {
// save the message in the drafts folder
$draftsFolder = $account->getDraftsFolder();
$raw = stream_get_contents($mail->getRaw());
$newUid = $draftsFolder->saveDraft($raw);
// delete old version if one exists
if (!is_null($uid)) {
$folderId = $draftsFolder->getFolderId();
$this->logger->debug("deleting outdated draft <$uid> in folder <$folderId>");
$draftsFolder->setMessageFlag($uid, \Horde_Imap_Client::FLAG_DELETED, true);
$account->deleteDraft($uid);
$this->logger->debug("draft <$uid> deleted");
}
} catch (\Horde_Exception $ex) {
$this->logger->error('Saving draft failed: ' . $ex->getMessage());
return new JSONResponse(
[
'message' => $ex->getMessage()
],
Http::STATUS_INTERNAL_SERVER_ERROR
);
}
return new JSONResponse(
[
'uid' => $newUid
]);
}
/**
* @NoAdminRequired
* @param string $term

Просмотреть файл

@ -121,9 +121,8 @@ class MessagesController extends Controller
$account = $this->getAccount();
$m = $mailBox->getMessage($id);
$json = $m->getFullMessage($account->getEmail(), ($mailBox->getSpecialRole() === 'sent'));
$json['senderImage'] = $this->contactsIntegration->getPhoto($json['fromEmail']);
$json = $m->getFullMessage($account->getEmail(), $mailBox->getSpecialRole());
$json['senderImage'] = $this->contactsIntegration->getPhoto($m->getFromEmail());
if (isset($json['hasHtmlBody'])){
$json['htmlBodyUrl'] = $this->buildHtmlBodyUrl($accountId, $folderId, $id);
}
@ -132,7 +131,7 @@ class MessagesController extends Controller
$json['attachment'] = $this->enrichDownloadUrl($accountId, $folderId, $id, $json['attachment']);
}
if (isset($json['attachments'])) {
$json['attachments'] = array_map(function($a) use($accountId, $folderId, $id) {
$json['attachments'] = array_map(function($a) use ($accountId, $folderId, $id) {
return $this->enrichDownloadUrl($accountId, $folderId, $id, $a);
}, $json['attachments']);
}

Просмотреть файл

@ -336,6 +336,26 @@ class Mailbox {
]
]);
}
/**
* Save draft
*
* @param string $rawBody
* @return int UID of the saved draft
*/
public function saveDraft($rawBody) {
$uids = $this->conn->append($this->mailBox, [
[
'data' => $rawBody,
'flags' => [
Horde_Imap_Client::FLAG_DRAFT,
Horde_Imap_Client::FLAG_SEEN
]
]
]);
return $uids->current();
}
/**
* @param int $uid

Просмотреть файл

@ -349,7 +349,7 @@ class Message {
/**
* @param string $ownMail
*/
public function getFullMessage($ownMail, $fromSent = false) {
public function getFullMessage($ownMail, $specialRole=null) {
$mailBody = $this->plainMessage;
$data = $this->getListArray();
@ -358,7 +358,7 @@ class Message {
} else {
$mailBody = $this->htmlService->convertLinks($mailBody);
list($mailBody, $signature) = $this->htmlService->parseMailBody($mailBody);
$data['body'] = nl2br($mailBody);
$data['body'] = $specialRole === 'drafts' ? $mailBody : nl2br($mailBody);
$data['signature'] = $signature;
}
@ -369,7 +369,7 @@ class Message {
$data['attachments'] = $this->attachments;
}
if ($fromSent) {
if ($specialRole === 'sent') {
$data['replyToList'] = $this->getToList();
$data['replyCcList'] = $this->getCCList();
} else {

Просмотреть файл

@ -156,17 +156,17 @@ script('mail', 'jquery-visibility');
class="hidden"
{{/unless}}
>
<input type="text" name="cc" id="cc" class="recipient-autocomplete"
<input type="text" name="cc" id="cc" value="{{replyCc}}" class="recipient-autocomplete"
value="{{printAddressListPlain replyCcList}}" />
<label id="cc-label" for="cc" class="transparency"><?php p($l->t('cc')); ?></label>
<!--
<input type="text" name="bcc" id="bcc" class="recipient-autocomplete" />
<input type="text" name="bcc" id="bcc" value="{{replyBcc}}" class="recipient-autocomplete" />
<label id="bcc-label" for="bcc" class="transparency"><?php p($l->t('bcc')); ?></label>
-->
</div>
<textarea name="body" class="reply-message-body"
placeholder="<?php p($l->t('Reply …')); ?>"></textarea>
placeholder="<?php p($l->t('Reply …')); ?>">{{replyBody}}</textarea>
<input class="reply-message-send primary" type="submit" value="<?php p($l->t('Reply')) ?>" disabled>
</div>
<div class="reply-message-more">
@ -202,18 +202,18 @@ script('mail', 'jquery-visibility');
<div id="new-message-fields">
<a href="#" id="new-message-cc-bcc-toggle"
class="transparency"><?php p($l->t('+ cc/bcc')); ?></a>
<input type="text" name="to" id="to" class="recipient-autocomplete" />
<input type="text" name="to" id="to" value="{{to}}" class="recipient-autocomplete" />
<label id="to-label" for="to" class="transparency"><?php p($l->t('to')); ?></label>
<div id="new-message-cc-bcc">
<input type="text" name="cc" id="cc" class="recipient-autocomplete" />
<input type="text" name="cc" id="cc" value="{{cc}}" class="recipient-autocomplete" />
<label id="cc-label" for="cc" class="transparency"><?php p($l->t('cc')); ?></label>
<input type="text" name="bcc" id="bcc" class="recipient-autocomplete" />
<input type="text" name="bcc" id="bcc" value="{{bcc}}" class="recipient-autocomplete" />
<label id="bcc-label" for="bcc" class="transparency"><?php p($l->t('bcc')); ?></label>
</div>
<input type="text" name="subject" id="subject"
<input type="text" name="subject" id="subject" value="{{subject}}"
placeholder="<?php p($l->t('Subject')); ?>" />
<textarea name="body" id="new-message-body"
placeholder="<?php p($l->t('Message …')); ?>"></textarea>
placeholder="<?php p($l->t('Message …')); ?>">{{message}}</textarea>
<input id="new-message-send" class="send primary" type="submit" value="<?php p($l->t('Send')) ?>" disabled>
</div>
<div id="new-message-attachments">