mirror of
https://github.com/the-djmaze/snappymail.git
synced 2024-11-13 02:55:24 +08:00
425 lines
15 KiB
PHP
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;
|
|
}
|
|
}
|