snappymail/plugins/avatars/index.php
2024-08-27 16:47:44 +02:00

425 lines
15 KiB
PHP

<?php
/**
* You may store your own custom domain icons in `data/_data_/_default_/avatars/`
* Like: `data/_data_/_default_/avatars/snappymail.eu.svg`
*/
class AvatarsPlugin extends \RainLoop\Plugins\AbstractPlugin
{
const
NAME = 'Avatars',
AUTHOR = 'SnappyMail',
URL = 'https://snappymail.eu/',
VERSION = '1.20',
RELEASE = '2024-08-26',
REQUIRED = '2.33.0',
CATEGORY = 'Contacts',
LICENSE = 'MIT',
DESCRIPTION = 'Show graphic of sender in message and messages list (supports BIMI, Gravatar, favicon and identicon, Contacts is still TODO)';
public function Init() : void
{
$this->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'])
)
) try {
// Base64Url
return \SnappyMail\Crypt::EncryptUrlSafe($mFrom['email']);
} catch (\Throwable $e) {
\SnappyMail\Log::error('Crypt', $e->getMessage());
}
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}/Encrypted(${from.email})
* Nextcloud Mail uses insecure unencrypted 'index.php/apps/mail/api/avatars/url/local%40example.com'
*/
// public function ServiceAvatar(...$aParts)
public function ServiceAvatar(string $sServiceName, string $sBimi, string $sEncryptedEmail)
{
$maxAge = 86400;
$sEmail = \SnappyMail\Crypt::DecryptUrlSafe($sEncryptedEmail);
$aBimi = \explode('-', $sBimi, 2);
$sBimiSelector = isset($aBimi[1]) ? $aBimi[1] : 'default';
// $sEmail && \MailSo\Base\Http::setETag("{$sBimiSelector}-{$sEncryptedEmail}");
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;
}
// Only allow service icon when DKIM is valid. $bBimi is true when DKIM is valid.
private static function getServiceIcon(string $sEmail) : ?string
{
$sDomain = \explode('@', $sEmail);
$sDomain = \array_pop($sDomain);
$aServices = [
"services/{$sDomain}",
'services/' . static::serviceDomain($sDomain)
];
foreach ($aServices as $service) {
$file = __DIR__ . "/images/{$service}.png";
if (\file_exists($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/'.\md5(\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) {
$aServices = [
"services/{$sDomain}",
'services/' . static::serviceDomain($sDomain)
];
foreach ($aServices as $service) {
$file = __DIR__ . "/images/{$service}.png";
if (\file_exists($file)) {
\MailSo\Base\Http::setLastModified(\filemtime($file));
$aResult = [
'image/png',
\file_get_contents($file)
];
break;
}
}
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?
<link sizes="16x16" rel="shortcut icon" type="image/x-icon" href="/..." />
<link sizes="16x16" rel="shortcut icon" type="image/png" href="/..." />
<link sizes="32x32" rel="shortcut icon" type="image/png" href="/..." />
<link sizes="96x96" rel="shortcut icon" type="image/png" href="/..." />
<link sizes="36x36" rel="icon" type="image/png" href="/..." />
<link sizes="48x48" rel="icon" type="image/png" href="/..." />
<link sizes="72x72" rel="icon" type="image/png" href="/..." />
<link sizes="96x96" rel="icon" type="image/png" href="/..." />
<link sizes="144x144" rel="icon" type="image/png" href="/..." />
<link sizes="192x192" rel="icon" type="image/png" href="/..." />
<link sizes="57x57" rel="apple-touch-icon" type="image/png" href="/..." />
<link sizes="60x60" rel="apple-touch-icon" type="image/png" href="/..." />
<link sizes="72x72" rel="apple-touch-icon" type="image/png" href="/..." />
<link sizes="76x76" rel="apple-touch-icon" type="image/png" href="/..." />
<link sizes="114x114" rel="apple-touch-icon" type="image/png" href="/..." />
<link sizes="120x120" rel="apple-touch-icon" type="image/png" href="/..." />
<link sizes="144x144" rel="apple-touch-icon" type="image/png" href="/..." />
<link sizes="152x152" rel="apple-touch-icon" type="image/png" href="/" />
<link sizes="180x180" rel="apple-touch-icon" type="image/png" href="/..." />
<link sizes="192x192" rel="apple-touch-icon" type="image/png" href="/..." />
*/
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;
}
}