Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 56 additions & 54 deletions lib/Service/MailTransmission.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
use Horde_Imap_Client_Data_Fetch;
use Horde_Imap_Client_Fetch_Query;
use Horde_Imap_Client_Ids;
use Horde_Mail_Rfc822_Address;
use Horde_Mail_Rfc822_List;
use Horde_Mail_Transport_Null;
use Horde_Mail_Transport_Smtphorde;
use Horde_Mime_Exception;
Expand Down Expand Up @@ -42,7 +44,6 @@
use OCA\Mail\Exception\ServiceException;
use OCA\Mail\IMAP\IMAPClientFactory;
use OCA\Mail\IMAP\MessageMapper;
use OCA\Mail\Model\Message as ModelMessage;
use OCA\Mail\Model\NewMessageData;
use OCA\Mail\Service\DataUri\DataUriParser;
use OCA\Mail\SMTP\SmtpClientFactory;
Expand Down Expand Up @@ -107,13 +108,13 @@ public function sendMessage(Account $account, LocalMessage $localMessage): void

$transport = $this->smtpClientFactory->create($account);
// build mime body
$headers = [
'From' => $from->toHorde(),
'To' => $to->toHorde(),
'Cc' => $cc->toHorde(),
'Bcc' => $bcc->toHorde(),
'Subject' => $localMessage->getSubject(),
];
$headers = $this->buildHeaders(
$from,
$to,
$cc,
$bcc,
$localMessage->getSubject()
);

// The table (oc_local_messages) currently only allows for a single reply to message id
// but we already set the 'references' header for an email so we could support multiple references
Expand Down Expand Up @@ -197,41 +198,27 @@ public function saveLocalDraft(Account $account, LocalMessage $message): void {

$perfLogger = $this->performanceLogger->start('save local draft');

$imapMessage = new ModelMessage();
$imapMessage->setTo($to);
$imapMessage->setSubject($message->getSubject());
$from = new AddressList([
Address::fromRaw($account->getName(), $account->getEMailAddress()),
]);
$imapMessage->setFrom($from);
$imapMessage->setCC($cc);
$imapMessage->setBcc($bcc);
if ($message->isHtml() === true) {
$imapMessage->setContent($message->getBodyHtml());
} else {
$imapMessage->setContent($message->getBodyPlain());
}
$from = Address::fromRaw($account->getName(), $account->getEMailAddress());

foreach ($attachments as $attachment) {
$this->transmissionService->handleAttachment($account, $attachment);
}

// build mime body
$headers = [
'From' => $imapMessage->getFrom()->first()->toHorde(),
'To' => $imapMessage->getTo()->toHorde(),
'Cc' => $imapMessage->getCC()->toHorde(),
'Bcc' => $imapMessage->getBCC()->toHorde(),
'Subject' => $imapMessage->getSubject(),
'Date' => Horde_Mime_Headers_Date::create(),
Comment thread
DerDreschner marked this conversation as resolved.
];
$headers = $this->buildHeaders(
$from,
$to,
$cc,
$bcc,
$message->getSubject()
);

$mail = new Horde_Mime_Mail();
$mail->addHeaders($headers);
if ($message->isHtml()) {
$mail->setHtmlBody($imapMessage->getContent());
$mail->setHtmlBody($message->getBodyHtml());
} else {
$mail->setBody($imapMessage->getContent());
$mail->setBody($message->getBodyPlain());
}
$mail->addHeaderOb(Horde_Mime_Headers_MessageId::create());
$perfLogger->step('build local draft message');
Expand Down Expand Up @@ -297,33 +284,22 @@ public function saveDraft(NewMessageData $message, ?Message $previousDraft = nul
$perfLogger->step('emit pre event');

$account = $message->getAccount();
$imapMessage = new ModelMessage();
$imapMessage->setTo($message->getTo());
$imapMessage->setSubject($message->getSubject());
$from = new AddressList([
Address::fromRaw($account->getName(), $account->getEMailAddress()),
]);
$imapMessage->setFrom($from);
$imapMessage->setCC($message->getCc());
$imapMessage->setBcc($message->getBcc());
$imapMessage->setContent($message->getBody());

// build mime body
$headers = [
'From' => $imapMessage->getFrom()->first()->toHorde(),
'To' => $imapMessage->getTo()->toHorde(),
'Cc' => $imapMessage->getCC()->toHorde(),
'Bcc' => $imapMessage->getBCC()->toHorde(),
'Subject' => $imapMessage->getSubject(),
'Date' => Horde_Mime_Headers_Date::create(),
Comment thread
DerDreschner marked this conversation as resolved.
];
$from = Address::fromRaw($account->getName(), $account->getEMailAddress());

$headers = $this->buildHeaders(
$from,
$message->getTo(),
$message->getCc(),
$message->getBcc(),
$message->getSubject()
);

$mail = new Horde_Mime_Mail();
$mail->addHeaders($headers);
if ($message->isHtml()) {
$mail->setHtmlBody($imapMessage->getContent());
$mail->setHtmlBody($message->getBody());
} else {
$mail->setBody($imapMessage->getContent());
$mail->setBody($message->getBody());
}
$mail->addHeaderOb(Horde_Mime_Headers_MessageId::create());
$perfLogger->step('build draft message');
Expand Down Expand Up @@ -365,6 +341,32 @@ public function saveDraft(NewMessageData $message, ?Message $previousDraft = nul
return [$account, $draftsMailbox, $newUid];
}

/**
* @return array{
* From: Horde_Mail_Rfc822_Address,
* To: Horde_Mail_Rfc822_List,
* Subject: string|null,
* Cc?: Horde_Mail_Rfc822_List,
* Bcc?: Horde_Mail_Rfc822_List,
* }
*/
private function buildHeaders(Address $from, AddressList $to, AddressList $cc, AddressList $bcc, ?string $subject): array {
$headers = [
'From' => $from->toHorde(),
'To' => $to->toHorde(),
'Subject' => $subject,
];
Comment on lines +353 to +358
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

buildHeaders() accepts ?string $subject and always sets a Subject header even when the value could be null. In this codebase LocalMessage::getSubject() and NewMessageData::getSubject() are both non-nullable strings, so this can be tightened to string $subject to avoid ever emitting an empty Subject: header and to better reflect actual call sites.

Copilot uses AI. Check for mistakes.

if (count($cc) > 0) {
$headers['Cc'] = $cc->toHorde();
}
if (count($bcc) > 0) {
$headers['Bcc'] = $bcc->toHorde();
}

return $headers;
}

#[\Override]
public function sendMdn(Account $account, Mailbox $mailbox, Message $message): void {
$query = new Horde_Imap_Client_Fetch_Query();
Expand Down
98 changes: 89 additions & 9 deletions tests/Unit/Service/MailTransmissionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
use Horde_Imap_Client_Socket;
use Horde_Mail_Transport;
use OCA\Mail\Account;
use OCA\Mail\Address;
use OCA\Mail\AddressList;
use OCA\Mail\Contracts\IMailManager;
use OCA\Mail\Db\Alias;
use OCA\Mail\Db\LocalAttachment;
Expand Down Expand Up @@ -136,7 +138,6 @@ public function testSendNewMessageSmimeError() {
}

public function testSendMessageFromAlias() {
// Arrange
$mailAccount = new MailAccount();
$mailAccount->setName('Bob');
$mailAccount->setEmail('bob@example.org');
Expand Down Expand Up @@ -167,17 +168,14 @@ public function testSendMessageFromAlias() {
->method('getEncryptMimePart')
->willReturnCallback(static fn ($localMessage, $to, $cc, $bcc, $account, $mimePart) => $mimePart);

// Act
$this->transmission->sendMessage($account, $localMessage);

// Assert
$this->assertEquals(LocalMessage::STATUS_RAW, $localMessage->getStatus());
$this->assertStringContainsString('From: Info <info@example.org', $localMessage->getRaw());
$this->assertStringContainsString('Disposition-Notification-To: Info <info@example.org>', $localMessage->getRaw());
}

public function testSendMessageAliasFallbackName() {
// Arrange
$mailAccount = new MailAccount();
$mailAccount->setName('Bob');
$mailAccount->setEmail('bob@example.org');
Expand Down Expand Up @@ -207,17 +205,14 @@ public function testSendMessageAliasFallbackName() {
->method('getEncryptMimePart')
->willReturnCallback(static fn ($localMessage, $to, $cc, $bcc, $account, $mimePart) => $mimePart);

// Act
$this->transmission->sendMessage($account, $localMessage);

// Assert
$this->assertEquals(LocalMessage::STATUS_RAW, $localMessage->getStatus());
$this->assertStringContainsString('From: Bob <info@example.org', $localMessage->getRaw());
$this->assertStringContainsString('Disposition-Notification-To: Bob <info@example.org>', $localMessage->getRaw());
}

public function testSendMessageAliasDoesNotExist() {
// Arrange
$mailAccount = new MailAccount();
$mailAccount->setName('Bob');
$mailAccount->setEmail('bob@example.org');
Expand All @@ -244,10 +239,8 @@ public function testSendMessageAliasDoesNotExist() {
->method('getEncryptMimePart')
->willReturnCallback(static fn ($localMessage, $to, $cc, $bcc, $account, $mimePart) => $mimePart);

// Act
$this->transmission->sendMessage($account, $localMessage);

// Assert
$this->assertEquals(LocalMessage::STATUS_RAW, $localMessage->getStatus());
$this->assertStringContainsString('From: Bob <bob@example.org', $localMessage->getRaw());
$this->assertStringContainsString('Disposition-Notification-To: Bob <bob@example.org>', $localMessage->getRaw());
Expand Down Expand Up @@ -433,4 +426,91 @@ public function testCreateDraftsMailboxAndSave(): void {

$this->transmission->saveLocalDraft(new Account($mailAccount), $localMessage);
}

public function testSendMessageCc() {
$mailAccount = new MailAccount();
$mailAccount->setName('Bob');
$mailAccount->setEmail('bob@mail.example');
$mailAccount->setUserId('bob');
$mailAccount->setSentMailboxId(123);
$account = new Account($mailAccount);
$localMessage = new LocalMessage();
$localMessage->setSubject('Test');
$localMessage->setBodyPlain('Test');
$localMessage->setHtml(false);
$transport = $this->createMock(Horde_Mail_Transport::class);
$this->smtpClientFactory->expects($this->once())
->method('create')
->willReturn($transport);
$this->transmissionService->expects(self::once())
->method('getSignMimePart')
->willReturnCallback(static fn ($localMessage, $account, $mimePart) => $mimePart);
$this->transmissionService->expects(self::once())
->method('getEncryptMimePart')
->willReturnCallback(static fn ($localMessage, $to, $cc, $bcc, $account, $mimePart) => $mimePart);
$this->transmissionService->expects(self::exactly(3))
->method('getAddressList')
->willReturnCallback(function ($localMessage, $type) {
$addresses = [];

if ($type === Recipient::TYPE_CC) {
$addresses[] = Address::fromRaw('Alice', 'alice@mail.example');
}

if ($type === Recipient::TYPE_BCC) {
$addresses[] = Address::fromRaw('Jane', 'jane@mail.example');
}

return new AddressList($addresses);
});


$this->transmission->sendMessage($account, $localMessage);

$this->assertEquals(LocalMessage::STATUS_RAW, $localMessage->getStatus());
$this->assertStringContainsString('From: Bob <bob@mail.example>', $localMessage->getRaw());
$this->assertStringContainsString('Cc: Alice <alice@mail.example>', $localMessage->getRaw());
Comment thread
kesselb marked this conversation as resolved.
$this->assertStringNotContainsString('Bcc:', $localMessage->getRaw());
}

public function testSendMessageOmitCc() {
$mailAccount = new MailAccount();
$mailAccount->setName('Bob');
$mailAccount->setEmail('bob@example.org');
$mailAccount->setUserId('bob');
$mailAccount->setSentMailboxId(123);
$account = new Account($mailAccount);
$localMessage = new LocalMessage();
$localMessage->setSubject('Test');
$localMessage->setBodyPlain('Test');
$localMessage->setHtml(false);
$transport = $this->createMock(Horde_Mail_Transport::class);
$this->smtpClientFactory->expects($this->once())
->method('create')
->willReturn($transport);
$this->transmissionService->expects(self::once())
->method('getSignMimePart')
->willReturnCallback(static fn ($localMessage, $account, $mimePart) => $mimePart);
$this->transmissionService->expects(self::once())
->method('getEncryptMimePart')
->willReturnCallback(static fn ($localMessage, $to, $cc, $bcc, $account, $mimePart) => $mimePart);
$this->transmissionService->expects(self::exactly(3))
->method('getAddressList')
->willReturnCallback(static function ($message, $type) {
$addresses = [];

if ($type === Recipient::TYPE_TO) {
$addresses[] = Address::fromRaw('Alice', 'alice@mail.example');
}

return new AddressList($addresses);
});

$this->transmission->sendMessage($account, $localMessage);

$this->assertEquals(LocalMessage::STATUS_RAW, $localMessage->getStatus());
$this->assertStringContainsString('From: Bob <bob@example.org>', $localMessage->getRaw());
$this->assertStringNotContainsString('Cc:', $localMessage->getRaw());
Comment thread
kesselb marked this conversation as resolved.
$this->assertStringNotContainsString('Bcc:', $localMessage->getRaw());
}
}
Loading