Skip to content

Commit

Permalink
Implement Reply-To header support when replying
Browse files Browse the repository at this point in the history
The Reply-To header, if present, will take precedence over the From
header. If the Reply-To data is undefined old behaviour is used.

Note that Horde libraries appear to internally determine reply_to
property in the Horde_Imap_Client_Data_Envelope (unless Dovecot does it,
but doubt). It was observed that reply_to property had the same value as
from property in a mail that contained no Reply-To header. It is assumed
that the Horde implementation makes sense, so just use the value as-is.

This notably fixes replying to mail from software reliant on the
Reply-To header such as GitLab and GitHub notifications and many types
of mail based ticketing (support) systems.

Signed-off-by: Melvin Vermeeren <[email protected]>
  • Loading branch information
vermeeren committed Sep 21, 2023
1 parent ad3df70 commit e1d75c0
Show file tree
Hide file tree
Showing 5 changed files with 48 additions and 11 deletions.
11 changes: 7 additions & 4 deletions lib/Model/IMAPMessage.php
Original file line number Diff line number Diff line change
Expand Up @@ -528,6 +528,7 @@ public function jsonSerialize() {
'messageId' => $this->getMessageId(),
'from' => $this->getFrom()->jsonSerialize(),
'to' => $this->getTo()->jsonSerialize(),
'replyTo' => $this->getReplyTo()->jsonSerialize(),
'cc' => $this->getCC()->jsonSerialize(),
'bcc' => $this->getBCC()->jsonSerialize(),
'subject' => $this->getSubject(),
Expand Down Expand Up @@ -739,17 +740,19 @@ public function setInReplyTo(string $id) {
/**
* @return AddressList
*/
public function getReplyTo() {
public function getReplyTo(): AddressList {
return AddressList::fromHorde($this->getEnvelope()->reply_to);
}

/**
* @param string $id
* @param AddressList $replyTo
*
* @throws Exception
*
* @return void
*/
public function setReplyTo(string $id) {
throw new Exception('not implemented');
public function setReplyTo(AddressList $replyTo) {
throw new Exception('IMAP message is immutable');
}

/**
Expand Down
10 changes: 10 additions & 0 deletions lib/Model/IMessage.php
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,16 @@ public function getTo(): AddressList;
*/
public function setTo(AddressList $to);

/**
* @return AddressList
*/
public function getReplyTo(): AddressList;

/**
* @param AddressList $replyTo
*/
public function setReplyTo(AddressList $replyTo);

/**
* @return AddressList
*/
Expand Down
20 changes: 20 additions & 0 deletions lib/Model/Message.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ class Message implements IMessage {
/** @var AddressList */
private $to;

/** @var AddressList */
private $replyTo;

/** @var AddressList */
private $cc;

Expand All @@ -63,6 +66,7 @@ class Message implements IMessage {
public function __construct() {
$this->from = new AddressList();
$this->to = new AddressList();
$this->replyTo = new AddressList();
$this->cc = new AddressList();
$this->bcc = new AddressList();
}
Expand Down Expand Up @@ -126,6 +130,22 @@ public function setTo(AddressList $to) {
$this->to = $to;
}

/**
* @return AddressList
*/
public function getReplyTo(): AddressList {
return $this->replyTo;
}

/**
* @param AddressList $replyTo
*
* @return void
*/
public function setReplyTo(AddressList $replyTo) {
$this->replyTo = $replyTo;
}

/**
* @return AddressList
*/
Expand Down
14 changes: 9 additions & 5 deletions src/ReplyBuilder.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,15 @@ const RecipientType = Object.seal({
Cc: 2,
})

export const buildRecipients = (envelope, ownAddress) => {
export const buildRecipients = (envelope, ownAddress, replyTo) => {
let recipientType = RecipientType.None
const isOwnAddress = (a) => a.email === ownAddress.email
const isNotOwnAddress = negate(isOwnAddress)

// The Reply-To header has higher precedence than the From header.
// This re-uses Horde's handling of the reply_to field directly.
const from = replyTo !== undefined ? replyTo : envelope.from

// Locate why we received this envelope
// Can be in 'to', 'cc' or unknown
let replyingAddress = envelope.to.find(isOwnAddress)
Expand All @@ -97,28 +101,28 @@ export const buildRecipients = (envelope, ownAddress) => {
if (recipientType === RecipientType.To) {
// Send to everyone except yourself, plus the original sender if not ourself
to = envelope.to.filter(isNotOwnAddress)
to = to.concat(envelope.from.filter(isNotOwnAddress))
to = to.concat(from.filter(isNotOwnAddress))

// CC remains the same
cc = envelope.cc
} else if (recipientType === RecipientType.Cc) {
// Send to the same people, plus the sender if not ourself
to = envelope.to.concat(envelope.from.filter(isNotOwnAddress))
to = envelope.to.concat(from.filter(isNotOwnAddress))

// All CC values are being kept except the replying address
cc = envelope.cc.filter(isNotOwnAddress)
} else {
// Send to the same recipient and the sender (if not ourself) -> answer all
to = envelope.to
to = to.concat(envelope.from.filter(isNotOwnAddress))
to = to.concat(from.filter(isNotOwnAddress))

// Keep CC values
cc = envelope.cc
}

// edge case: pure self-sent email
if (to.length === 0) {
to = envelope.from
to = from
}

return {
Expand Down
4 changes: 2 additions & 2 deletions src/store/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -345,7 +345,7 @@ export default {
commit('showMessageComposer', {
data: {
accountId: reply.data.accountId,
to: reply.data.from,
to: original.replyTo !== undefined ? original.replyTo : reply.data.from,
cc: [],
subject: buildReplySubject(reply.data.subject),
body: data.body,
Expand All @@ -360,7 +360,7 @@ export default {
const recipients = buildReplyRecipients(reply.data, {
email: account.emailAddress,
label: account.name,
})
}, original.replyTo)
commit('showMessageComposer', {
data: {
accountId: reply.data.accountId,
Expand Down

0 comments on commit e1d75c0

Please sign in to comment.