addCss('style.css'); $this->addJs('avatars.js'); $this->addJsonHook('Avatar', 'DoAvatar'); $this->addPartHook('Avatar', 'ServiceAvatar'); $identicon = $this->Config()->Get('plugin', 'identicon', ''); if ($identicon && \is_file(__DIR__ . "/{$identicon}.js")) { $this->addJs("{$identicon}.js"); } // https://github.com/the-djmaze/snappymail/issues/714 if ($this->Config()->Get('plugin', 'service', true) // || !$this->Config()->Get('plugin', 'delay', true) || $this->Config()->Get('plugin', 'gravatar', false) || $this->Config()->Get('plugin', 'bimi', false) || $this->Config()->Get('plugin', 'favicon', false) ) { $this->addHook('json.after-message', 'JsonMessage'); $this->addHook('json.after-messagelist', 'JsonMessageList'); } // https://www.ietf.org/archive/id/draft-brand-indicators-for-message-identification-04.html#bimi-selector if ($this->Config()->Get('plugin', 'bimi', false)) { $this->addHook('imap.message-headers', 'ImapMessageHeaders'); } } public function ImapMessageHeaders(array &$aHeaders) { // \MailSo\Mime\Enumerations\Header::BIMI_SELECTOR $aHeaders[] = 'BIMI-Selector'; } public function JsonMessage(array &$aResponse) { if ($icon = $this->JsonAvatar($aResponse['Result'])) { $aResponse['Result']['avatar'] = $icon; } } public function JsonMessageList(array &$aResponse) { if (!empty($aResponse['Result']['@Collection'])) { foreach ($aResponse['Result']['@Collection'] as &$message) { if ($icon = $this->JsonAvatar($message)) { $message['avatar'] = $icon; } } } } private function JsonAvatar($message) : ?string { $mFrom = empty($message['from'][0]) ? null : $message['from'][0]; if ($mFrom instanceof \MailSo\Mime\Email) { $mFrom = $mFrom->jsonSerialize(); } if (\is_array($mFrom)) { if (/*!$this->Config()->Get('plugin', 'delay', true) && */($this->Config()->Get('plugin', 'gravatar', false) || ($this->Config()->Get('plugin', 'bimi', false) && 'pass' == $mFrom['dkimStatus']) || ($this->Config()->Get('plugin', 'favicon', false) && 'pass' == $mFrom['dkimStatus']) ) ) { return 'remote'; } if ('pass' == $mFrom['dkimStatus'] && $this->Config()->Get('plugin', 'service', true)) { // 'data:image/png;base64,[a-zA-Z0-9+/=]' return static::getServiceIcon($mFrom['email']); } } return null; } /** * POST method handling */ public function DoAvatar() : array { $bBimi = !empty($this->jsonParam('bimi')); $sBimiSelector = $this->jsonParam('bimiSelector') ?: ''; $sEmail = $this->jsonParam('email'); $aResult = $this->getAvatar($sEmail, $bBimi, $sBimiSelector); if ($aResult) { $aResult = [ 'type' => $aResult[0], 'data' => \base64_encode($aResult[1]) ]; } return $this->jsonResponse(__FUNCTION__, $aResult); } /** * GET /?Avatar/${bimi}/Encoded(${from.email}) * Nextcloud Mail uses insecure unencoded 'index.php/apps/mail/api/avatars/url/local%40example.com' */ // public function ServiceAvatar(...$aParts) public function ServiceAvatar(string $sServiceName, string $sBimi, string $sEncodedEmail) { $maxAge = 86400; $sEmail = \MailSo\Base\Utils::UrlSafeBase64Decode($sEncodedEmail); $aBimi = \explode('-', $sBimi, 2); $sBimiSelector = isset($aBimi[1]) ? $aBimi[1] : 'default'; // $sEmail && \MailSo\Base\Http::setETag("{$sBimiSelector}-{$sEncodedEmail}"); if ($sEmail && ($aResult = $this->getAvatar($sEmail, !empty($aBimi[0]), $sBimiSelector))) { \header("Cache-Control: max-age={$maxAge}, private"); \header('Expires: '.\gmdate('D, j M Y H:i:s', $maxAge + \time()).' UTC'); \header('Content-Type: '.$aResult[0]); echo $aResult[1]; } else { \MailSo\Base\Http::StatusHeader(404); } exit; } protected function configMapping() : array { $group = new \RainLoop\Plugins\PropertyCollection('Lookup'); $group->exchangeArray([ \RainLoop\Plugins\Property::NewInstance('delay')->SetLabel('Delay lookup') ->SetType(\RainLoop\Enumerations\PluginPropertyType::BOOL) ->SetAllowedInJs(true) ->SetDefaultValue(true), \RainLoop\Plugins\Property::NewInstance('bimi')->SetLabel('BIMI') ->SetType(\RainLoop\Enumerations\PluginPropertyType::BOOL) ->SetDefaultValue(false) ->SetDescription('https://bimigroup.org/ (DKIM header must be valid)'), \RainLoop\Plugins\Property::NewInstance('favicon')->SetLabel('Favicon') ->SetType(\RainLoop\Enumerations\PluginPropertyType::BOOL) ->SetDefaultValue(false) ->SetDescription('Fetch favicon from domain (DKIM header must be valid)'), \RainLoop\Plugins\Property::NewInstance('gravatar')->SetLabel('Gravatar') ->SetType(\RainLoop\Enumerations\PluginPropertyType::BOOL) ->SetDefaultValue(false) ->SetDescription('https://wikipedia.org/wiki/Gravatar'), ]); $aResult = array( defined('RainLoop\\Enumerations\\PluginPropertyType::SELECT') ? \RainLoop\Plugins\Property::NewInstance('identicon')->SetLabel('Identicon') ->SetType(\RainLoop\Enumerations\PluginPropertyType::SELECT) ->SetDefaultValue([ ['id' => '', 'name' => 'Name characters else silhouette'], ['id' => 'identicon', 'name' => 'Name characters else squares'], ['id' => 'jdenticon', 'name' => 'Triangles shape'] ]) ->SetDescription('https://wikipedia.org/wiki/Identicon') : \RainLoop\Plugins\Property::NewInstance('identicon')->SetLabel('Identicon') ->SetType(\RainLoop\Enumerations\PluginPropertyType::SELECTION) ->SetDefaultValue(['','identicon','jdenticon']) ->SetDescription('empty = default, identicon = squares, jdenticon = Triangles shape') , \RainLoop\Plugins\Property::NewInstance('service')->SetLabel('Preload valid domain icons') ->SetType(\RainLoop\Enumerations\PluginPropertyType::BOOL) ->SetAllowedInJs(true) ->SetDefaultValue(true) ->SetDescription('DKIM header must be valid and icon is found in avatars/images/services directory'), $group ); /* if (\class_exists('OC') && isset(\OC::$server)) { $aResult[] = \RainLoop\Plugins\Property::NewInstance('nextcloud')->SetLabel('Lookup Nextcloud Contacts') ->SetType(\RainLoop\Enumerations\PluginPropertyType::BOOL) // ->SetAllowedInJs(true) ->SetDefaultValue(false); } */ return $aResult; } private static function getServicePng(string $sDomain) : ?string { $aServices = [ "services/{$sDomain}", 'services/' . static::serviceDomain($sDomain) ]; foreach ($aServices as $service) { $file = __DIR__ . "/images/{$service}.png"; if (\file_exists($file)) { return $file; } } return null; } // Only allow service icon when DKIM is valid. $bBimi is true when DKIM is valid. private static function getServiceIcon(string $sEmail) : ?string { $aParts = \explode('@', $sEmail); $file = static::getServicePng(\array_pop($aParts)); if ($file) { return 'data:image/png;base64,' . \base64_encode(\file_get_contents($file)); } $aResult = static::getCachedImage($sEmail); if ($aResult) { return 'data:'.$aResult[0].';base64,' . \base64_encode($aResult[1]); } return null; } private function getAvatar(string $sEmail, bool $bBimi, string $sBimiSelector = '') : ?array { if (!\strpos($sEmail, '@')) { return null; } $sAsciiEmail = \mb_strtolower(\SnappyMail\IDN::emailToAscii($sEmail)); $sEmailId = \sha1($sAsciiEmail); \MailSo\Base\Http::setETag($sEmailId); \header('Cache-Control: private'); // \header('Expires: '.\gmdate('D, j M Y H:i:s', \time() + 86400).' UTC'); $aResult = static::getCachedImage($sEmail); if ($aResult) { return $aResult; } // TODO: lookup contacts vCard and return PHOTO value /* if (!$aResult) { $oActions = \RainLoop\Api::Actions(); $oAccount = $oActions->getAccountFromToken(); if ($oAccount) { $oAddressBookProvider = $oActions->AddressBookProvider($oAccount); if ($oAddressBookProvider) { $oContact = $oAddressBookProvider->GetContactByEmail($sEmail); if ($oContact && $oContact->vCard && $oContact->vCard['PHOTO']) { $aResult = [ 'text/vcard', $oContact->vCard ]; } } } } */ if (!$aResult) { $sDomain = \explode('@', $sEmail); $sDomain = \array_pop($sDomain); $aUrls = []; if ($this->Config()->Get('plugin', 'bimi', false)) { $BIMI = $bBimi ? \SnappyMail\DNS::BIMI($sDomain, $sBimiSelector) : null; if ($BIMI) { $aUrls[] = $BIMI; // $aResult = ['text/uri-list', $BIMI]; \SnappyMail\Log::debug('Avatar', "BIMI {$sDomain}: {$BIMI}"); } else { \SnappyMail\Log::notice('Avatar', "BIMI 404 for {$sDomain}"); } } if ($this->Config()->Get('plugin', 'gravatar', false)) { $aUrls[] = 'https://gravatar.com/avatar/'.\hash('sha256', \strtolower($sAsciiEmail)).'?s=80&d=404'; } foreach ($aUrls as $sUrl) { if ($aResult = static::getUrl($sUrl)) { break; } } } if ($aResult) { static::cacheImage($sEmail, $aResult); } // Only allow service icon when DKIM is valid. $bBimi is true when DKIM is valid. if ($bBimi && !$aResult) { $file = static::getServicePng($sDomain); if ($file) { \MailSo\Base\Http::setLastModified(\filemtime($file)); $aResult = [ 'image/png', \file_get_contents($file) ]; } if (!$aResult && $this->Config()->Get('plugin', 'favicon', false)) { $aResult = static::getFavicon($sDomain); } } return $aResult; } private static function serviceDomain(string $sDomain) : string { $sDomain = \preg_replace('/^(.+\\.)?(paypal\\.[a-z][a-z])$/D', 'paypal.com', $sDomain); $sDomain = \preg_replace('/^facebookmail.com$/D', 'facebook.com', $sDomain); $sDomain = \preg_replace('/^dhlparcel.nl$/D', 'dhl.com', $sDomain); $sDomain = \preg_replace('/^amazon.nl$/D', 'amazon.com', $sDomain); $sDomain = \preg_replace('/^.+\\.([^.]+\\.[^.]+)$/D', '$1', $sDomain); return $sDomain; } private static function cacheImage(string $sEmail, array $aResult) : void { if (!\is_dir(\APP_PRIVATE_DATA . 'avatars')) { \mkdir(\APP_PRIVATE_DATA . 'avatars', 0700); } $sEmailId = \mb_strtolower(\SnappyMail\IDN::emailToAscii($sEmail)); if (\str_contains($sEmail, '@')) { $sEmailId = \sha1($sEmailId); } \file_put_contents( \APP_PRIVATE_DATA . 'avatars/' . $sEmailId . \SnappyMail\File\MimeType::toExtension($aResult[0]), $aResult[1] ); \MailSo\Base\Http::setLastModified(\time()); } private static function getCachedImage(string $sEmail) : ?array { $sEmail = \mb_strtolower(\SnappyMail\IDN::emailToAscii($sEmail)); $aFiles = \glob(\APP_PRIVATE_DATA . "avatars/{$sEmail}.*"); if (!$aFiles && \str_contains($sEmail, '@')) { $sEmailId = \sha1($sEmail); $aFiles = \glob(\APP_PRIVATE_DATA . "avatars/{$sEmailId}.*"); if (!$aFiles) { $sDomain = \explode('@', $sEmail); $sDomain = \array_pop($sDomain); $aFiles = \glob(\APP_PRIVATE_DATA . "avatars/{$sDomain}.*"); } } if ($aFiles) { return [ \SnappyMail\File\MimeType::fromFile($aFiles[0]), \file_get_contents($aFiles[0]) ]; } return null; } private static function getFavicon(string $sDomain) : ?array { $aResult = static::getUrl('https://' . $sDomain . '/favicon.ico') ?: static::getUrl('https://' . static::serviceDomain($sDomain) . '/favicon.ico') ?: static::getUrl('https://www.' . static::serviceDomain($sDomain) . '/favicon.ico') ?: static::getUrl("https://www.google.com/s2/favicons?sz=48&domain_url={$sDomain}") ?: static::getUrl("https://api.faviconkit.com/{$sDomain}/48") // ?: static::getUrl("https://api.statvoo.com/favicon/{$sDomain}") ; /* Also detect the following? */ if ($aResult) { static::cacheImage($sDomain, $aResult); } return $aResult; } private static function getUrl(string $sUrl) : ?array { $oHTTP = \SnappyMail\HTTP\Request::factory(/*'socket' or 'curl'*/); $oHTTP->proxy = \RainLoop\Api::Config()->Get('labs', 'curl_proxy', ''); $oHTTP->proxy_auth = \RainLoop\Api::Config()->Get('labs', 'curl_proxy_auth', ''); $oHTTP->max_response_kb = 0; $oHTTP->timeout = 15; // timeout in seconds. try { $oResponse = $oHTTP->doRequest('GET', $sUrl); if ($oResponse) { if (200 === $oResponse->status && \str_starts_with($oResponse->getHeader('content-type'), 'image/')) { return [ $oResponse->getHeader('content-type'), $oResponse->body ]; } \SnappyMail\Log::notice('Avatar', "error {$oResponse->status} for {$sUrl}"); } else { \SnappyMail\Log::warning('Avatar', "failed for {$sUrl}"); } } catch (\Throwable $e) { \SnappyMail\Log::notice('Avatar', "error {$e->getMessage()}"); } return null; } }