Changes for #89

Now it does not fetch the PGP signature, because validation was broken anyway.
Instead it validates multipart/signed according to RFC 3156 section 5 and returns details for the signed part:
* BodyPartId
* SigPartId
* MicAlg

So in the future several implementations (GnuPG, OpenPGP.js, etc.) can use the correct data for verification.
This commit is contained in:
the-djmaze 2022-01-17 15:58:23 +01:00
parent ba49d06d1a
commit 8dcd0cf833
14 changed files with 514 additions and 356 deletions

View file

@ -347,7 +347,7 @@ class AppUser extends AbstractApp {
}
reloadOpenPgpKeys() {
if (PgpUserStore.capaOpenPGP()) {
if (PgpUserStore.openpgp) {
const keys = [],
email = new EmailModel(),
openpgpKeyring = PgpUserStore.openpgpKeyring,
@ -784,7 +784,6 @@ class AppUser extends AbstractApp {
}
}
PgpUserStore.openpgpKeyring = new openpgp.Keyring();
PgpUserStore.capaOpenPGP(true);
this.reloadOpenPgpKeys();
};
script.onerror = () => console.error(script.src);

View file

@ -20,8 +20,6 @@ import { AbstractModel } from 'Knoin/AbstractModel';
import PreviewHTML from 'Html/PreviewMessage.html';
import { PgpUserStore } from 'Stores/User/Pgp';
const
/*eslint-disable max-len*/
url = /(^|[\s\n]|\/?>)(https:\/\/[-A-Z0-9+\u0026\u2019#/%?=()~_|!:,.;]*[-A-Z0-9+\u0026#/%=~()_|])/gi,
@ -102,7 +100,7 @@ export class MessageModel extends AbstractModel {
hasImages: false,
hasExternals: false,
isPgpSigned: false,
pgpSigned: null, // { BodyPartId: "1", SigPartId: "2", MicAlg: "pgp-sha256" }
isPgpEncrypted: false,
pgpSignedVerifyStatus: SignedVerifyStatus.None,
pgpSignedVerifyUser: '',
@ -181,7 +179,7 @@ export class MessageModel extends AbstractModel {
this.hasExternals(false);
this.attachments(new AttachmentCollectionModel);
this.isPgpSigned(false);
this.pgpSigned(null);
this.isPgpEncrypted(false);
this.pgpSignedVerifyStatus(SignedVerifyStatus.None);
this.pgpSignedVerifyUser('');
@ -404,7 +402,7 @@ export class MessageModel extends AbstractModel {
viewHtml() {
const body = this.body;
if (body && this.html()) {
let html = this.html().toString()
let html = this.html()
.replace(/font-size:\s*[0-9]px/g, 'font-size:11px')
// Strip utm_* tracking
.replace(/(\\?|&|&)utm_[a-z]+=[a-z0-9_-]*/si, '$1');
@ -465,7 +463,7 @@ export class MessageModel extends AbstractModel {
if (body && this.plain()) {
body.classList.toggle('html', 0);
body.classList.toggle('plain', 1);
body.innerHTML = plainToHtml(this.plain().toString())
body.innerHTML = plainToHtml(this.plain())
// Strip utm_* tracking
.replace(/(\\?|&|&)utm_[a-z]+=[a-z0-9_-]*/si, '$1')
.replace(url, '$1<a href="$2" target="_blank">$2</a>')
@ -479,8 +477,6 @@ export class MessageModel extends AbstractModel {
}
initView() {
PgpUserStore.initMessageBodyControls(this.body, this);
// init BlockquoteSwitcher
this.body.querySelectorAll('blockquote:not(.rl-bq-switcher)').forEach(node => {
if (node.textContent.trim() && !node.parentNode.closest('blockquote')) {

View file

@ -370,7 +370,7 @@ export const MessageUserStore = new class {
message.hasImages(body.rlHasImages);
} else {
body = Element.fromHTML('<div id="' + id + '" hidden="" class="b-text-part '
+ (message.isPgpSigned() ? ' openpgp-signed' : '')
+ (message.pgpSigned() ? ' openpgp-signed' : '')
+ (message.isPgpEncrypted() ? ' openpgp-encrypted' : '')
+ '">'
+ '</div>');

View file

@ -1,6 +1,5 @@
import ko from 'ko';
import { i18n } from 'Common/Translator';
import { isArray, arrayLength, pString, addComputablesTo } from 'Common/Utils';
import { AccountUserStore } from 'Stores/User/Account';
@ -9,132 +8,8 @@ import { showScreenPopup } from 'Knoin/Knoin';
import { MessageOpenPgpPopupView } from 'View/Popup/MessageOpenPgp';
function controlsHelper(dom, verControl, success, title, text)
{
dom.classList.toggle('error', !success);
dom.classList.toggle('success', success);
verControl.classList.toggle('error', !success);
verControl.classList.toggle('success', success);
dom.title = verControl.title = title;
if (undefined !== text) {
dom.textContent = text.trim();
}
}
function domControlEncryptedClickHelper(store, dom, armoredMessage, recipients) {
return function() {
let message = null;
if (this.classList.contains('success')) {
return false;
}
try {
message = store.openpgp.message.readArmored(armoredMessage);
} catch (e) {
console.log(e);
}
if (message && message.getText && message.verify && message.decrypt) {
store.decryptMessage(
message,
recipients,
(validPrivateKey, decryptedMessage, validPublicKey, signingKeyIds) => {
if (decryptedMessage) {
if (validPublicKey) {
controlsHelper(
dom,
this,
true,
i18n('PGP_NOTIFICATIONS/GOOD_SIGNATURE', {
USER: validPublicKey.user + ' (' + validPublicKey.id + ')'
}),
decryptedMessage.getText()
);
} else if (validPrivateKey) {
const keyIds = arrayLength(signingKeyIds) ? signingKeyIds : null,
additional = keyIds
? keyIds.map(item => (item && item.toHex ? item.toHex() : null)).filter(v => v).join(', ')
: '';
controlsHelper(
dom,
this,
false,
i18n('PGP_NOTIFICATIONS/UNVERIFIRED_SIGNATURE') + (additional ? ' (' + additional + ')' : ''),
decryptedMessage.getText()
);
} else {
controlsHelper(dom, this, false, i18n('PGP_NOTIFICATIONS/DECRYPTION_ERROR'));
}
} else {
controlsHelper(dom, this, false, i18n('PGP_NOTIFICATIONS/DECRYPTION_ERROR'));
}
}
);
return false;
}
controlsHelper(dom, this, false, i18n('PGP_NOTIFICATIONS/DECRYPTION_ERROR'));
return false;
};
}
function domControlSignedClickHelper(store, dom, armoredMessage) {
return function() {
let message = null;
if (this.classList.contains('success') || this.classList.contains('error')) {
return false;
}
try {
message = store.openpgp.cleartext.readArmored(armoredMessage);
} catch (e) {
console.log(e);
}
if (message && message.getText && message.verify) {
store.verifyMessage(message, (validKey, signingKeyIds) => {
if (validKey) {
controlsHelper(
dom,
this,
true,
i18n('PGP_NOTIFICATIONS/GOOD_SIGNATURE', {
USER: validKey.user + ' (' + validKey.id + ')'
}),
message.getText()
);
} else {
const keyIds = arrayLength(signingKeyIds) ? signingKeyIds : null,
additional = keyIds
? keyIds.map(item => (item && item.toHex ? item.toHex() : null)).filter(v => v).join(', ')
: '';
controlsHelper(
dom,
this,
false,
i18n('PGP_NOTIFICATIONS/UNVERIFIRED_SIGNATURE') + (additional ? ' (' + additional + ')' : '')
);
}
});
return false;
}
controlsHelper(dom, this, false, i18n('PGP_NOTIFICATIONS/DECRYPTION_ERROR'));
return false;
};
}
export const PgpUserStore = new class {
constructor() {
this.capaOpenPGP = ko.observable(false);
this.openpgp = null;
this.openpgpkeys = ko.observableArray();
@ -335,28 +210,4 @@ export const PgpUserStore = new class {
return false;
}
/**
* @param {*} dom
* @param {MessageModel} rainLoopMessage
*/
initMessageBodyControls(dom, rainLoopMessage) {
const cl = dom.classList,
signed = cl.contains('openpgp-signed'),
encrypted = cl.contains('openpgp-encrypted');
if (encrypted || signed) {
const
domText = dom.textContent,
recipients = rainLoopMessage ? rainLoopMessage.getEmails(['from', 'to', 'cc']) : [],
verControl = Element.fromHTML('<div class="b-openpgp-control"><i class="fontastic">🔒</i></div>');
if (encrypted) {
verControl.title = i18n('MESSAGE/PGP_ENCRYPTED_MESSAGE_DESC');
verControl.addEventListener('click', domControlEncryptedClickHelper(this, dom, domText, recipients));
} else {
verControl.title = i18n('MESSAGE/PGP_SIGNED_MESSAGE_DESC');
verControl.addEventListener('click', domControlSignedClickHelper(this, dom, domText));
}
dom.prepend(verControl);
}
}
};

View file

@ -429,27 +429,28 @@ html.rl-no-preview-pane {
}
}
*/
.b-openpgp-control {
}
color: #FA0;
cursor: pointer;
display: block;
opacity: 0.5;
margin: 15px;
.b-openpgp-control {
&:hover {
opacity: 1;
}
color: #FA0;
cursor: pointer;
display: block;
opacity: 0.5;
margin: 15px;
&.success {
color: green;
opacity: 1;
}
&:hover {
opacity: 1;
}
&.error {
color: red;
opacity: 1;
}
&.success {
color: green;
opacity: 1;
}
&.error {
color: red;
opacity: 1;
}
}
}

View file

@ -131,7 +131,7 @@ class ComposePopupView extends AbstractViewPopup {
this.bSkipNextHide = false;
this.capaOpenPGP = PgpUserStore.capaOpenPGP;
this.capaOpenPGP = !!PgpUserStore.openpgp;
this.identities = IdentityUserStore;
@ -552,7 +552,7 @@ class ComposePopupView extends AbstractViewPopup {
}
openOpenPgpPopup() {
if (PgpUserStore.capaOpenPGP() && !this.oEditor.isHtml()) {
if (PgpUserStore.openpgp && !this.oEditor.isHtml()) {
showScreenPopup(ComposeOpenPgpPopupView, [
result => this.editor(editor => editor.setPlain(result)),
this.oEditor.getData(false),

View file

@ -40,6 +40,67 @@ import Remote from 'Remote/User/Fetch';
import { decorateKoCommands, createCommand } from 'Knoin/Knoin';
import { AbstractViewRight } from 'Knoin/AbstractViews';
import { PgpUserStore } from 'Stores/User/Pgp';
function controlsHelper(dom, verControl, success, title, text)
{
dom.classList.toggle('error', !success);
dom.classList.toggle('success', success);
// verControl.classList.toggle('error', !success);
// verControl.classList.toggle('success', success);
dom.title = verControl.title = title;
if (undefined !== text) {
dom.textContent = text.trim();
}
}
function pgpClickHelper(dom, armoredMessage) {
if (dom.classList.contains('success') || dom.classList.contains('error')) {
return;
}
let message = null;
try {
message = PgpUserStore.openpgp.cleartext.readArmored(armoredMessage);
} catch (e) {
console.log(e);
}
if (message && message.getText && message.verify) {
PgpUserStore.verifyMessage(message, (validKey, signingKeyIds) => {
console.dir([validKey, signingKeyIds]);
/*
if (validKey) {
controlsHelper(
dom,
this,
true,
i18n('PGP_NOTIFICATIONS/GOOD_SIGNATURE', {
USER: validKey.user + ' (' + validKey.id + ')'
}),
message.getText()
);
} else {
const keyIds = arrayLength(signingKeyIds) ? signingKeyIds : null,
additional = keyIds
? keyIds.map(item => (item && item.toHex ? item.toHex() : null)).filter(v => v).join(', ')
: '';
controlsHelper(
dom,
this,
false,
i18n('PGP_NOTIFICATIONS/UNVERIFIRED_SIGNATURE') + (additional ? ' (' + additional + ')' : '')
);
}
*/
});
} else {
controlsHelper(dom, this, false, i18n('PGP_NOTIFICATIONS/DECRYPTION_ERROR'));
}
}
export class MailMessageView extends AbstractViewRight {
constructor() {
super('MailMessageView');
@ -176,6 +237,12 @@ export class MailMessageView extends AbstractViewRight {
return '';
},
pgpSigned: () => PgpUserStore.openpgp
&& MessageUserStore.message() && !!MessageUserStore.message().pgpSigned(),
pgpEncrypted: () => PgpUserStore.openpgp
&& MessageUserStore.message() && MessageUserStore.message().isPgpEncrypted(),
messageListOrViewLoading:
() => MessageUserStore.listIsLoading() | MessageUserStore.messageLoading()
});
@ -614,4 +681,14 @@ export class MailMessageView extends AbstractViewRight {
rl.app.reloadFlagsCurrentMessageListAndMessageFromCache();
}
}
pgpDecrypt(self/*, event*/) {
const message = self.message();
message && pgpClickHelper(message.body, message.plain(), message.getEmails(['from', 'to', 'cc']));
}
pgpVerify(self/*, event*/) {
const message = self.message();
message && pgpClickHelper(message.body, message.plain());
}
}

View file

@ -138,6 +138,11 @@ class BodyStructure
return $this->sLocation;
}
public function SubParts() : array
{
return $this->aSubParts;
}
public function IsInline() : bool
{
return 'inline' === $this->sDisposition || \strlen($this->sContentID);
@ -163,6 +168,17 @@ class BodyStructure
return 'doc' === \MailSo\Base\Utils::ContentTypeType($this->sContentType, $this->sFileName);
}
public function IsPgpSigned() : bool
{
// https://datatracker.ietf.org/doc/html/rfc3156#section-5
return 'multipart/signed' === $this->sContentType
&& !empty($this->aBodyParams['protocol'])
&& 'application/pgp-signature' === \strtolower(\trim($this->aBodyParams['protocol']))
// The multipart/signed body MUST consist of exactly two parts.
&& 2 === \count($this->aSubParts)
&& $this->aSubParts[1]->IsPgpSignature();
}
public function IsPgpSignature() : bool
{
return \in_array($this->sContentType,

View file

@ -15,6 +15,8 @@ namespace MailSo\Imap\Enumerations;
* @category MailSo
* @package Imap
* @subpackage Enumerations
*
* https://datatracker.ietf.org/doc/html/rfc3501#section-6.4.5
*/
abstract class FetchType
{
@ -25,18 +27,25 @@ abstract class FetchType
// Macro equivalent to: (FLAGS INTERNALDATE RFC822.SIZE ENVELOPE BODY)
const FULL = 'FULL';
const HEADER = 'HEADER'; // ([RFC-2822] header of the message)
const TEXT = 'TEXT'; // ([RFC-2822] text body of the message)
const MIME = 'MIME'; // ([MIME-IMB] header)
// Non-extensible form of BODYSTRUCTURE
const BODY = 'BODY';
// An alternate form of BODY[<section>] that does not implicitly set the \Seen flag.
const BODY_PEEK = 'BODY.PEEK';
// The text of a particular body section.
const BODY_HEADER = 'BODY[HEADER]';
const BODY_HEADER_PEEK = 'BODY.PEEK[HEADER]';
const BODYSTRUCTURE = 'BODYSTRUCTURE';
const ENVELOPE = 'ENVELOPE';
const FLAGS = 'FLAGS';
const INTERNALDATE = 'INTERNALDATE';
const RFC822 = 'RFC822';
const RFC822_HEADER = 'RFC822.HEADER';
// const RFC822 = 'RFC822'; // Functionally equivalent to BODY[]
// const RFC822_HEADER = 'RFC822.HEADER'; // Functionally equivalent to BODY.PEEK[HEADER]
const RFC822_SIZE = 'RFC822.SIZE';
const RFC822_TEXT = 'RFC822.TEXT';
// const RFC822_TEXT = 'RFC822.TEXT'; // Functionally equivalent to BODY[TEXT]
const UID = 'UID';
// RFC 3516
const BINARY = 'BINARY';

View file

@ -158,6 +158,7 @@ class MailClient
$aFetchItems = array(
FetchType::UID,
// FetchType::FAST,
FetchType::RFC822_SIZE,
FetchType::INTERNALDATE,
FetchType::FLAGS,
@ -180,12 +181,18 @@ class MailClient
$aFetchItems[] = $sLine;
}
$gSignatureParts = $oBodyStructure->SearchByContentType('application/pgp-signature');
foreach ($gSignatureParts as $oPart)
{
$aFetchItems[] = FetchType::BODY_PEEK.'['.$oPart->PartID().']';
/*
$gSignatureParts = $oBodyStructure->SearchByContentType('multipart/signed');
foreach ($gSignatureParts as $oPart) {
if ($oPart->IsPgpSigned()) {
// An empty section specification refers to the entire message, including the header.
// But Dovecot does not return it with BODY.PEEK[1], so we also use BODY.PEEK[1.MIME].
$aFetchItems[] = FetchType::BODY_PEEK.'['.$oPart->SubParts()[0]->PartID().'.MIME]';
$aFetchItems[] = FetchType::BODY_PEEK.'['.$oPart->SubParts()[0]->PartID().']';
$aFetchItems[] = FetchType::BODY_PEEK.'['.$oPart->SubParts()[1]->PartID().']';
}
}
*/
}
}
@ -197,8 +204,7 @@ class MailClient
$aFetchResponse = $this->oImapClient->Fetch($aFetchItems, $iIndex, $bIndexIsUid);
if (\count($aFetchResponse))
{
$oMessage = Message::NewFetchResponseInstance(
$sFolderName, $aFetchResponse[0], $oBodyStructure);
$oMessage = Message::NewFetchResponseInstance($sFolderName, $aFetchResponse[0], $oBodyStructure);
}
return $oMessage;

View file

@ -79,9 +79,7 @@ class Message implements \JsonSerializable
$bTextPartIsTrimmed = false,
$sPgpSignature = '',
$sPgpSignatureMicAlg = '',
$bPgpSigned = false,
$aPgpSigned = null,
$bPgpEncrypted = false;
function __construct()
@ -99,19 +97,9 @@ class Message implements \JsonSerializable
return $this->sHtml;
}
public function PgpSignature() : string
public function PgpSigned() : ?array
{
return $this->sPgpSignature;
}
public function PgpSignatureMicAlg() : string
{
return $this->sPgpSignatureMicAlg;
}
public function isPgpSigned() : bool
{
return $this->bPgpSigned;
return $this->aPgpSigned;
}
public function isPgpEncrypted() : bool
@ -286,11 +274,8 @@ class Message implements \JsonSerializable
public static function NewFetchResponseInstance(string $sFolder, \MailSo\Imap\FetchResponse $oFetchResponse, ?\MailSo\Imap\BodyStructure $oBodyStructure = null) : self
{
return (new self)->InitByFetchResponse($sFolder, $oFetchResponse, $oBodyStructure);
}
$oMessage = new self;
public function InitByFetchResponse(string $sFolder, \MailSo\Imap\FetchResponse $oFetchResponse, ?\MailSo\Imap\BodyStructure $oBodyStructure = null) : self
{
if (!$oBodyStructure)
{
$oBodyStructure = $oFetchResponse->GetFetchBodyStructure();
@ -299,12 +284,12 @@ class Message implements \JsonSerializable
$sInternalDate = $oFetchResponse->GetFetchValue(FetchType::INTERNALDATE);
$aFlags = $oFetchResponse->GetFetchValue(FetchType::FLAGS);
$this->sFolder = $sFolder;
$this->iUid = (int) $oFetchResponse->GetFetchValue(FetchType::UID);
$this->iSize = (int) $oFetchResponse->GetFetchValue(FetchType::RFC822_SIZE);
$this->aFlagsLowerCase = \array_map('strtolower', $aFlags ?: []);
$oMessage->sFolder = $sFolder;
$oMessage->iUid = (int) $oFetchResponse->GetFetchValue(FetchType::UID);
$oMessage->iSize = (int) $oFetchResponse->GetFetchValue(FetchType::RFC822_SIZE);
$oMessage->aFlagsLowerCase = \array_map('strtolower', $aFlags ?: []);
$this->iInternalTimeStampInUTC =
$oMessage->iInternalTimeStampInUTC =
\MailSo\Base\DateTimeHelper::ParseInternalDateString($sInternalDate);
$sCharset = $oBodyStructure ? Utils::NormalizeCharset($oBodyStructure->SearchCharset()) : '';
@ -331,33 +316,33 @@ class Message implements \JsonSerializable
$bCharsetAutoDetect = !\strlen($sCharset);
$this->sSubject = $oHeaders->ValueByName(\MailSo\Mime\Enumerations\Header::SUBJECT, $bCharsetAutoDetect);
$this->sMessageId = $oHeaders->ValueByName(\MailSo\Mime\Enumerations\Header::MESSAGE_ID);
$this->sContentType = $oHeaders->ValueByName(\MailSo\Mime\Enumerations\Header::CONTENT_TYPE);
$oMessage->sSubject = $oHeaders->ValueByName(\MailSo\Mime\Enumerations\Header::SUBJECT, $bCharsetAutoDetect);
$oMessage->sMessageId = $oHeaders->ValueByName(\MailSo\Mime\Enumerations\Header::MESSAGE_ID);
$oMessage->sContentType = $oHeaders->ValueByName(\MailSo\Mime\Enumerations\Header::CONTENT_TYPE);
$this->oFrom = $oHeaders->GetAsEmailCollection(\MailSo\Mime\Enumerations\Header::FROM_, $bCharsetAutoDetect);
$this->oTo = $oHeaders->GetAsEmailCollection(\MailSo\Mime\Enumerations\Header::TO_, $bCharsetAutoDetect);
$this->oCc = $oHeaders->GetAsEmailCollection(\MailSo\Mime\Enumerations\Header::CC, $bCharsetAutoDetect);
$this->oBcc = $oHeaders->GetAsEmailCollection(\MailSo\Mime\Enumerations\Header::BCC, $bCharsetAutoDetect);
$oMessage->oFrom = $oHeaders->GetAsEmailCollection(\MailSo\Mime\Enumerations\Header::FROM_, $bCharsetAutoDetect);
$oMessage->oTo = $oHeaders->GetAsEmailCollection(\MailSo\Mime\Enumerations\Header::TO_, $bCharsetAutoDetect);
$oMessage->oCc = $oHeaders->GetAsEmailCollection(\MailSo\Mime\Enumerations\Header::CC, $bCharsetAutoDetect);
$oMessage->oBcc = $oHeaders->GetAsEmailCollection(\MailSo\Mime\Enumerations\Header::BCC, $bCharsetAutoDetect);
if ($this->oFrom) {
$oHeaders->PopulateEmailColectionByDkim($this->oFrom);
if ($oMessage->oFrom) {
$oHeaders->PopulateEmailColectionByDkim($oMessage->oFrom);
}
$this->oSender = $oHeaders->GetAsEmailCollection(\MailSo\Mime\Enumerations\Header::SENDER, $bCharsetAutoDetect);
$this->oReplyTo = $oHeaders->GetAsEmailCollection(\MailSo\Mime\Enumerations\Header::REPLY_TO, $bCharsetAutoDetect);
$this->oDeliveredTo = $oHeaders->GetAsEmailCollection(\MailSo\Mime\Enumerations\Header::DELIVERED_TO, $bCharsetAutoDetect);
$oMessage->oSender = $oHeaders->GetAsEmailCollection(\MailSo\Mime\Enumerations\Header::SENDER, $bCharsetAutoDetect);
$oMessage->oReplyTo = $oHeaders->GetAsEmailCollection(\MailSo\Mime\Enumerations\Header::REPLY_TO, $bCharsetAutoDetect);
$oMessage->oDeliveredTo = $oHeaders->GetAsEmailCollection(\MailSo\Mime\Enumerations\Header::DELIVERED_TO, $bCharsetAutoDetect);
$this->sInReplyTo = $oHeaders->ValueByName(\MailSo\Mime\Enumerations\Header::IN_REPLY_TO);
$this->sReferences = Utils::StripSpaces(
$oMessage->sInReplyTo = $oHeaders->ValueByName(\MailSo\Mime\Enumerations\Header::IN_REPLY_TO);
$oMessage->sReferences = Utils::StripSpaces(
$oHeaders->ValueByName(\MailSo\Mime\Enumerations\Header::REFERENCES));
$sHeaderDate = $oHeaders->ValueByName(\MailSo\Mime\Enumerations\Header::DATE);
$this->sHeaderDate = $sHeaderDate;
$this->iHeaderTimeStampInUTC = \MailSo\Base\DateTimeHelper::ParseRFC2822DateString($sHeaderDate);
$oMessage->sHeaderDate = $sHeaderDate;
$oMessage->iHeaderTimeStampInUTC = \MailSo\Base\DateTimeHelper::ParseRFC2822DateString($sHeaderDate);
// Priority
$this->iPriority = \MailSo\Mime\Enumerations\MessagePriority::NORMAL;
$oMessage->iPriority = \MailSo\Mime\Enumerations\MessagePriority::NORMAL;
$sPriority = $oHeaders->ValueByName(\MailSo\Mime\Enumerations\Header::X_MSMAIL_PRIORITY);
if (!\strlen($sPriority))
{
@ -376,7 +361,7 @@ class Message implements \JsonSerializable
case '2(high)':
case '1':
case '2':
$this->iPriority = \MailSo\Mime\Enumerations\MessagePriority::HIGH;
$oMessage->iPriority = \MailSo\Mime\Enumerations\MessagePriority::HIGH;
break;
case 'low':
@ -384,78 +369,78 @@ class Message implements \JsonSerializable
case '5(lowest)':
case '4':
case '5':
$this->iPriority = \MailSo\Mime\Enumerations\MessagePriority::LOW;
$oMessage->iPriority = \MailSo\Mime\Enumerations\MessagePriority::LOW;
break;
}
}
// Delivery Receipt
$this->sDeliveryReceipt = \trim($oHeaders->ValueByName(\MailSo\Mime\Enumerations\Header::RETURN_RECEIPT_TO));
$oMessage->sDeliveryReceipt = \trim($oHeaders->ValueByName(\MailSo\Mime\Enumerations\Header::RETURN_RECEIPT_TO));
// Read Receipt
$this->sReadReceipt = \trim($oHeaders->ValueByName(\MailSo\Mime\Enumerations\Header::DISPOSITION_NOTIFICATION_TO));
if (empty($this->sReadReceipt))
$oMessage->sReadReceipt = \trim($oHeaders->ValueByName(\MailSo\Mime\Enumerations\Header::DISPOSITION_NOTIFICATION_TO));
if (empty($oMessage->sReadReceipt))
{
$this->sReadReceipt = \trim($oHeaders->ValueByName(\MailSo\Mime\Enumerations\Header::X_CONFIRM_READING_TO));
$oMessage->sReadReceipt = \trim($oHeaders->ValueByName(\MailSo\Mime\Enumerations\Header::X_CONFIRM_READING_TO));
}
// Unsubscribe links
$this->aUnsubsribeLinks = $oHeaders->ValueByName(\MailSo\Mime\Enumerations\Header::LIST_UNSUBSCRIBE);
if (empty($this->aUnsubsribeLinks))
$oMessage->aUnsubsribeLinks = $oHeaders->ValueByName(\MailSo\Mime\Enumerations\Header::LIST_UNSUBSCRIBE);
if (empty($oMessage->aUnsubsribeLinks))
{
$this->aUnsubsribeLinks = array();
$oMessage->aUnsubsribeLinks = array();
}
else
{
$this->aUnsubsribeLinks = explode(',', $this->aUnsubsribeLinks);
$this->aUnsubsribeLinks = array_map(
$oMessage->aUnsubsribeLinks = explode(',', $oMessage->aUnsubsribeLinks);
$oMessage->aUnsubsribeLinks = array_map(
function ($link) {
return trim($link, ' <>');
},
$this->aUnsubsribeLinks
$oMessage->aUnsubsribeLinks
);
}
if ($spam = $oHeaders->ValueByName(\MailSo\Mime\Enumerations\Header::X_SPAMD_RESULT)) {
if (\preg_match('/\\[([\\d\\.-]+)\\s*\\/\\s*([\\d\\.]+)\\];/', $spam, $match)) {
if ($threshold = \floatval($match[2])) {
$this->iSpamScore = \intval(\max(0, \min(100, 100 * \floatval($match[1]) / $threshold)));
$this->sSpamResult = "{$match[1]} / {$match[2]}";
$oMessage->iSpamScore = \intval(\max(0, \min(100, 100 * \floatval($match[1]) / $threshold)));
$oMessage->sSpamResult = "{$match[1]} / {$match[2]}";
}
}
$this->bIsSpam = false !== \stripos($this->sSubject, '*** SPAM ***');
$oMessage->bIsSpam = false !== \stripos($oMessage->sSubject, '*** SPAM ***');
} else if ($spam = $oHeaders->ValueByName(\MailSo\Mime\Enumerations\Header::X_BOGOSITY)) {
$this->sSpamResult = $spam;
$this->bIsSpam = !!\preg_match('/yes|spam/', $spam);
$oMessage->sSpamResult = $spam;
$oMessage->bIsSpam = !!\preg_match('/yes|spam/', $spam);
if (\preg_match('/spamicity=([\\d\\.]+)/', $spam, $spamicity)) {
$this->iSpamScore = \intval(\max(0, \min(100, \floatval($spamicity[1]))));
$oMessage->iSpamScore = \intval(\max(0, \min(100, \floatval($spamicity[1]))));
}
} else if ($spam = $oHeaders->ValueByName(\MailSo\Mime\Enumerations\Header::X_SPAM_STATUS)) {
$this->sSpamResult = $spam;
$oMessage->sSpamResult = $spam;
if (\preg_match('/(?:hits|score)=([\\d\\.-]+)/', $spam, $value)
&& \preg_match('/required=([\\d\\.-]+)/', $spam, $required)) {
if ($threshold = \floatval($required[1])) {
$this->iSpamScore = \intval(\max(0, \min(100, 100 * \floatval($value[1]) / $threshold)));
$this->sSpamResult = "{$value[1]} / {$required[1]}";
$oMessage->iSpamScore = \intval(\max(0, \min(100, 100 * \floatval($value[1]) / $threshold)));
$oMessage->sSpamResult = "{$value[1]} / {$required[1]}";
}
}
$this->bIsSpam = 'Yes' === \substr($spam, 0, 3);
$oMessage->bIsSpam = 'Yes' === \substr($spam, 0, 3);
// $spam = $oHeaders->ValueByName(\MailSo\Mime\Enumerations\Header::X_SPAM_FLAG);
// $this->bIsSpam = false !== \stripos($spam, 'YES');
// $oMessage->bIsSpam = false !== \stripos($spam, 'YES');
}
if ($virus = $oHeaders->ValueByName(\MailSo\Mime\Enumerations\Header::X_VIRUS)) {
$this->bHasVirus = true;
$oMessage->bHasVirus = true;
}
if ($virus = $oHeaders->ValueByName(\MailSo\Mime\Enumerations\Header::X_VIRUS_STATUS)) {
if (false !== \stripos($spam, 'infected')) {
$this->bHasVirus = true;
$oMessage->bHasVirus = true;
} else if (false !== \stripos($spam, 'clean')) {
$this->bHasVirus = false;
$oMessage->bHasVirus = false;
}
}
if ($virus = $oHeaders->ValueByName(\MailSo\Mime\Enumerations\Header::X_VIRUS_SCANNED)) {
$this->sVirusScanned = $virus;
$oMessage->sVirusScanned = $virus;
}
$sDraftInfo = $oHeaders->ValueByName(\MailSo\Mime\Enumerations\Header::X_DRAFT_INFO);
@ -481,7 +466,7 @@ class Message implements \JsonSerializable
}
if (\strlen($sType) && \strlen($sFolder) && $iUid) {
$this->aDraftInfo = array($sType, $iUid, $sFolder);
$oMessage->aDraftInfo = array($sType, $iUid, $sFolder);
}
}
}
@ -490,119 +475,141 @@ class Message implements \JsonSerializable
$sCharset = $sCharset ?: \MailSo\Base\Enumerations\Charset::ISO_8859_1;
// date, subject, from, sender, reply-to, to, cc, bcc, in-reply-to, message-id
$this->sMessageId = $oFetchResponse->GetFetchEnvelopeValue(9, '');
$this->sSubject = Utils::DecodeHeaderValue($oFetchResponse->GetFetchEnvelopeValue(1, ''), $sCharset);
$oMessage->sMessageId = $oFetchResponse->GetFetchEnvelopeValue(9, '');
$oMessage->sSubject = Utils::DecodeHeaderValue($oFetchResponse->GetFetchEnvelopeValue(1, ''), $sCharset);
$this->oFrom = $oFetchResponse->GetFetchEnvelopeEmailCollection(2, $sCharset);
$this->oSender = $oFetchResponse->GetFetchEnvelopeEmailCollection(3, $sCharset);
$this->oReplyTo = $oFetchResponse->GetFetchEnvelopeEmailCollection(4, $sCharset);
$this->oTo = $oFetchResponse->GetFetchEnvelopeEmailCollection(5, $sCharset);
$this->oCc = $oFetchResponse->GetFetchEnvelopeEmailCollection(6, $sCharset);
$this->oBcc = $oFetchResponse->GetFetchEnvelopeEmailCollection(7, $sCharset);
$this->sInReplyTo = $oFetchResponse->GetFetchEnvelopeValue(8, '');
}
// Content-Type: multipart/signed; micalg="pgp-sha256"; protocol="application/pgp-signature"
if ('multipart/signed' === \strtolower($this->sContentType)
&& 'application/pgp-signature' === \strtolower($oHeaders->ParameterValue(\MailSo\Mime\Enumerations\Header::CONTENT_TYPE, \MailSo\Mime\Enumerations\Parameter::PROTOCOL)))
{
$gPgpSignatureParts = $oBodyStructure ? $oBodyStructure->SearchByContentType('application/pgp-signature') : null;
$this->bPgpSigned = $gPgpSignatureParts && $gPgpSignatureParts->valid();
if ($this->bPgpSigned) {
$sPgpSignatureText = $oFetchResponse->GetFetchValue(FetchType::BODY.'['.$gPgpSignatureParts->current()->PartID().']');
if (\is_string($sPgpSignatureText) && \strlen($sPgpSignatureText) && 0 < \strpos($sPgpSignatureText, 'BEGIN PGP SIGNATURE')) {
$this->sPgpSignature = \trim($sPgpSignatureText);
$this->sPgpSignatureMicAlg = (string) $oHeaders->ParameterValue(\MailSo\Mime\Enumerations\Header::CONTENT_TYPE, 'micalg');
}
}
$oMessage->oFrom = $oFetchResponse->GetFetchEnvelopeEmailCollection(2, $sCharset);
$oMessage->oSender = $oFetchResponse->GetFetchEnvelopeEmailCollection(3, $sCharset);
$oMessage->oReplyTo = $oFetchResponse->GetFetchEnvelopeEmailCollection(4, $sCharset);
$oMessage->oTo = $oFetchResponse->GetFetchEnvelopeEmailCollection(5, $sCharset);
$oMessage->oCc = $oFetchResponse->GetFetchEnvelopeEmailCollection(6, $sCharset);
$oMessage->oBcc = $oFetchResponse->GetFetchEnvelopeEmailCollection(7, $sCharset);
$oMessage->sInReplyTo = $oFetchResponse->GetFetchEnvelopeValue(8, '');
}
// Content-Type: multipart/encrypted; protocol="application/pgp-encrypted"
$this->bPgpEncrypted = ('multipart/encrypted' === \strtolower($this->sContentType)
$oMessage->bPgpEncrypted = ('multipart/encrypted' === \strtolower($oMessage->sContentType)
&& 'application/pgp-encrypted' === \strtolower($oHeaders->ParameterValue(\MailSo\Mime\Enumerations\Header::CONTENT_TYPE, \MailSo\Mime\Enumerations\Parameter::PROTOCOL)));
$aTextParts = $oBodyStructure ? $oBodyStructure->GetHtmlAndPlainParts() : null;
if ($aTextParts)
{
$sCharset = $sCharset ?: \MailSo\Base\Enumerations\Charset::UTF_8;
$aHtmlParts = array();
$aPlainParts = array();
foreach ($aTextParts as $oPart)
{
$sText = $oFetchResponse->GetFetchValue(FetchType::BODY.'['.$oPart->PartID().']');
if (null === $sText)
{
$sText = $oFetchResponse->GetFetchValue(FetchType::BODY.'['.$oPart->PartID().']<0>');
if (\is_string($sText) && \strlen($sText))
{
$this->bTextPartIsTrimmed = true;
}
}
if (\is_string($sText) && \strlen($sText))
{
$sTextCharset = $oPart->Charset();
if (empty($sTextCharset))
{
$sTextCharset = $sCharset;
}
$sTextCharset = Utils::NormalizeCharset($sTextCharset, true);
$sText = Utils::DecodeEncodingValue($sText, $oPart->MailEncodingName());
$sText = Utils::ConvertEncoding($sText, $sTextCharset, \MailSo\Base\Enumerations\Charset::UTF_8);
$sText = Utils::Utf8Clear($sText);
if ('text/html' === $oPart->ContentType())
{
$aHtmlParts[] = $sText;
}
else
{
if ($oPart->IsFlowedFormat())
{
$sText = Utils::DecodeFlowedFormat($sText);
}
$aPlainParts[] = $sText;
}
}
}
$this->sHtml = \implode('<br />', $aHtmlParts);
$this->sPlain = \trim(\implode("\n", $aPlainParts));
$aMatch = array();
if (!$this->bPgpSigned && \preg_match('/-----BEGIN PGP SIGNATURE-----(.+)-----END PGP SIGNATURE-----/ism', $this->sPlain, $aMatch) && !empty($aMatch[0]))
{
$this->sPgpSignature = \trim($aMatch[0]);
$this->bPgpSigned = true;
}
$this->bPgpEncrypted = !$this->bPgpEncrypted && false !== \stripos($this->sPlain, '-----BEGIN PGP MESSAGE-----');
unset($aHtmlParts, $aPlainParts, $aMatch);
}
if ($oBodyStructure)
{
$gSignatureParts = $oBodyStructure->SearchByContentType('multipart/signed');
foreach ($gSignatureParts as $oPart) {
if (!$oPart->IsPgpSigned()) {
continue;
}
$oPgpSignaturePart = $oBodyStructure->SubParts()[1];
$oMessage->aPgpSigned = [
// /?/Raw/&q[]=/0/Download/&q[]=/...
// /?/Raw/&q[]=/0/View/&q[]=/...
'BodyPartId' => $oBodyStructure->SubParts()[0]->PartID(),
'SigPartId' => $oPgpSignaturePart->PartID(),
'MicAlg' => (string) $oHeaders->ParameterValue(\MailSo\Mime\Enumerations\Header::CONTENT_TYPE, 'micalg')
];
/*
// An empty section specification refers to the entire message, including the header.
// But Dovecot does not return it with BODY.PEEK[1], so we also use BODY.PEEK[1.MIME].
$sPgpText = \trim(
\trim($oFetchResponse->GetFetchValue(FetchType::BODY.'['.$oMessage->aPgpSigned['BodyPartId'].'.MIME]'))
. "\r\n\r\n"
. \trim($oFetchResponse->GetFetchValue(FetchType::BODY.'['.$oMessage->aPgpSigned['BodyPartId'].']'))
);
if ($sPgpText) {
$oMessage->aPgpSigned['Body'] = $sPgpText;
}
$sPgpSignatureText = $oFetchResponse->GetFetchValue(FetchType::BODY.'['.$oMessage->aPgpSigned['SigPartId'].']');
if ($sPgpSignatureText && 0 < \strpos($sPgpSignatureText, 'BEGIN PGP SIGNATURE')) {
$oMessage->aPgpSigned['Signature'] = $oBodyStructure->SubParts()[0]->PartID();
}
*/
break;
}
$aTextParts = $oBodyStructure->GetHtmlAndPlainParts();
if ($aTextParts)
{
$sCharset = $sCharset ?: \MailSo\Base\Enumerations\Charset::UTF_8;
$aHtmlParts = array();
$aPlainParts = array();
foreach ($aTextParts as $oPart)
{
$sText = $oFetchResponse->GetFetchValue(FetchType::BODY.'['.$oPart->PartID().']');
if (null === $sText)
{
$sText = $oFetchResponse->GetFetchValue(FetchType::BODY.'['.$oPart->PartID().']<0>');
if (\is_string($sText) && \strlen($sText))
{
$oMessage->bTextPartIsTrimmed = true;
}
}
if (\is_string($sText) && \strlen($sText))
{
$sTextCharset = $oPart->Charset();
if (empty($sTextCharset))
{
$sTextCharset = $sCharset;
}
$sTextCharset = Utils::NormalizeCharset($sTextCharset, true);
$sText = Utils::DecodeEncodingValue($sText, $oPart->MailEncodingName());
$sText = Utils::ConvertEncoding($sText, $sTextCharset, \MailSo\Base\Enumerations\Charset::UTF_8);
$sText = Utils::Utf8Clear($sText);
if ('text/html' === $oPart->ContentType())
{
$aHtmlParts[] = $sText;
}
else
{
if ($oPart->IsFlowedFormat())
{
$sText = Utils::DecodeFlowedFormat($sText);
}
$aPlainParts[] = $sText;
}
}
}
$oMessage->sHtml = \implode('<br />', $aHtmlParts);
$oMessage->sPlain = \trim(\implode("\n", $aPlainParts));
$aMatch = array();
if (!$oMessage->aPgpSigned && \preg_match('/-----BEGIN PGP SIGNATURE-----.+?-----END PGP SIGNATURE-----/ism', $oMessage->sPlain, $aMatch))
{
$oMessage->aPgpSigned = [
// /?/Raw/&q[]=/0/Download/&q[]=/...
// /?/Raw/&q[]=/0/View/&q[]=/...
'BodyPartId' => 0,
'SigPartId' => 0,
'MicAlg' => '',
'Signature' => \trim($aMatch[0])
];
}
$oMessage->bPgpEncrypted = !$oMessage->bPgpEncrypted && false !== \stripos($oMessage->sPlain, '-----BEGIN PGP MESSAGE-----');
unset($aHtmlParts, $aPlainParts, $aMatch);
}
$gAttachmentsParts = $oBodyStructure->SearchAttachmentsParts();
if ($gAttachmentsParts->valid())
{
$this->oAttachments = new AttachmentCollection;
$oMessage->oAttachments = new AttachmentCollection;
foreach ($gAttachmentsParts as /* @var $oAttachmentItem \MailSo\Imap\BodyStructure */ $oAttachmentItem)
{
$this->oAttachments->append(
Attachment::NewBodyStructureInstance($this->sFolder, $this->iUid, $oAttachmentItem)
$oMessage->oAttachments->append(
Attachment::NewBodyStructureInstance($oMessage->sFolder, $oMessage->iUid, $oAttachmentItem)
);
}
}
}
return $this;
return $oMessage;
}
public function jsonSerialize()

View file

@ -254,10 +254,9 @@ trait Response
$mResult['Plain'] = $mResponse->Plain();
$mResult['isPgpSigned'] = $mResponse->isPgpSigned();
// $this->GetCapa(false, Capa::OPEN_PGP)
$mResult['isPgpEncrypted'] = $mResponse->isPgpEncrypted();
// $mResult['PgpSignature'] = $mResponse->PgpSignature();
// $mResult['PgpSignatureMicAlg'] = $mResponse->PgpSignatureMicAlg();
$mResult['PgpSigned'] = $mResponse->PgpSigned();
$mResult['HasExternals'] = $bHasExternals;
$mResult['HasInternals'] = \count($aFoundCIDs) || \count($aFoundContentLocationUrls);

View file

@ -0,0 +1,188 @@
<?php
/**
* https://datatracker.ietf.org/doc/html/draft-shaw-openpgp-hkp-00
* The last IETF draft for HKP also defines a distributed key server network, based on DNS SRV records:
* to find the key of someone@example.com, one can ask it by requesting example.com's key server.
*
* https://datatracker.ietf.org/doc/html/rfc4387
* https://datatracker.ietf.org/doc/html/rfc7929
*/
namespace SnappyMail\PGP;
abstract class Keyservers
{
public static $keyservers = [
/*
'hkp://keys.gnupg.net',
'hkps://keyserver.ubuntu.com',
keys.openpgp.org
pgp.mit.edu
keyring.debian.org
keyserver.ubuntu.com
attester.flowcrypt.com
zimmermann.mayfirst.org
*/
'https://keys.fedoraproject.org'
];
private static function fetch(string $host, string $op, string $search, bool $fingerprint = false, bool $exact = false) : ?Response
{
$search = \urlencode($search);
$fingerprint = $fingerprint ? '&fingerprint=on' : '';
$exact = $exact ? '&exact=on' : '';
return static::HTTP()->doRequest('GET', "{$host}/pks/lookup?op={$op}&options=mr{$fingerprint}&search={$search}");
}
private static $HTTP;
private static function HTTP() : \SnappyMail\HTTP\Request
{
if (!static::$HTTP) {
static::$HTTP = \SnappyMail\HTTP\Request::factory(/*'socket' or 'curl'*/);
static::$HTTP->max_response_kb = 0;
static::$HTTP->timeout = 15; // timeout in seconds.
}
return static::$HTTP;
}
/**
* Request the public key from the hkp servers
* Returns PGP PUBLIC KEY BLOCK
*/
public static function get(string $keyId) : string
{
// add the 0x prefix if absent
if ('0x' !== \substr($keyId, 0, 2)) {
$keyId = '0x' . $keyId;
}
foreach ($this->keyservers as $host) {
$oResponse = static::fetch($host, 'get', $keyId);
if (!$oResponse) {
\SnappyMail\Log::info('PGP', "No response for key {$keyId} on {$host}");
continue;
}
if (200 !== $oResponse->status) {
\SnappyMail\Log::info('PGP', "{$oResponse->status} for key {$keyId} on {$host}");
continue;
}
return $oResponse->body;
}
throw new \Exception('Could not obtain public key from the keyserver.');
}
/**
* Returns all matching keys found on a public keyserver.
*
* @param string $search String to search for (usually an email, name, or username).
* @param bool $fingerprint Provide the key fingerprint for each key.
* @param bool $exact Instruct the server to search for an exact match.
*
* @throws Exception
*/
public static function index(string $search, bool $fingerprint = true, bool $exact = false) : array
{
$keys = [];
foreach ($this->keyservers as $host) {
$oResponse = static::fetch($host, 'index', $search, $fingerprint, $exact);
if (!$oResponse) {
\SnappyMail\Log::info('PGP', "No response for key {$keyId} on {$host}");
continue;
}
if (200 !== $oResponse->status) {
\SnappyMail\Log::info('PGP', "{$oResponse->status} for search `{$search}` on {$host}");
continue;
}
$result = \explode("\n", $oResponse->body);
foreach ($result as $line) {
// https://datatracker.ietf.org/doc/html/draft-shaw-openpgp-hkp-00#section-5.2
$line = \explode(':', $line);
// pub:<keyid>:<algo>:<keylen>:<creationdate>:<expirationdate>:<flags>
if ('pub' === $line[0]) {
if ($curKey) {
$keys[] = $curKey;
$curKey = null;
}
// Ignore invalid line
if (7 !== \count($line)) {
\SnappyMail\Log::info('PGP', "Invalid pub line for search `{$search}` on {$host}");
continue;
}
// Ignore flagged or expired key
if (!empty($line[6]) || (!empty($line[5]) && $line[5] <= time())) {
continue;
}
$keyids[$line[4]] = $line[1];
$curKey = [
'keyid' => $line[1],
'host' => $host,
'algo' => \intval($line[2]), // https://datatracker.ietf.org/doc/html/rfc2440#section-9.1
'keylen' => \intval($line[3]),
'creationdate' => \strlen($line[4]) ? \intval($line[4]) : null,
'expirationdate' => \strlen($line[5]) ? \intval($line[5]) : null,
// 'revoked' => \str_contains($line[6], 'r'),
// 'disabled' => \str_contains($line[6], 'd'),
// 'expired' => \str_contains($line[6], 'e'),
'uids' => [],
];
}
// uid:<escaped uid string>:<creationdate>:<expirationdate>:<flags>
else if ('uid' === $line[0] && $curKey) {
// Ignore invalid line
if (5 !== \count($line)) {
\SnappyMail\Log::info('PGP', "Invalid uid line for search `{$search}` on {$host}");
continue;
}
// Ignore flagged or expired key
if (!empty($line[4]) || (!empty($line[3]) && $line[3] <= time())) {
continue;
}
$curKey['uids'][] = [
'uid' => \urldecode($line[1]),
'creationdate' => \strlen($line[2]) ? \intval($line[2]) : null,
'expirationdate' => \strlen($line[3]) ? \intval($line[3]) : null,
// 'revoked' => \str_contains($line[4], 'r'),
// 'disabled' => \str_contains($line[4], 'd'),
// 'expired' => \str_contains($line[4], 'e'),
];
}
}
if ($curKey) {
$keys[] = $curKey;
}
return $keys;
}
throw new \Exception('Could not obtain public key from the keyservers');
}
/**
* Sends a PGP public key to a public keyserver.
*
* @param string $host
* @param mixed $key The PGP public key.
*
* @throws Exception
*/
public static function add(string $host, string $key)
{
/*
$key = PublicKey::create($key);
if (!$this->get($key->id)) {
$keytext = \urlencode(\trim($key));
static::HTTP()->doRequest('POST', "{$host}/pks/add", 'keytext=' . $keytext, [
'Content-Type: application/x-www-form-urlencoded',
'Content-Length: ' . \strlen($keytext),
'Connection: close'
]);
}
*/
}
}

View file

@ -320,6 +320,15 @@
</div>
</div>
<div class="b-openpgp-control" data-bind="visible: pgpEncrypted, click: pgpDecrypt">
<i class="fontastic">🔒</i>
<span data-i18n="MESSAGE/PGP_ENCRYPTED_MESSAGE_DESC"></span>
</div>
<div class="b-openpgp-control" data-bind="visible: pgpSigned, click: pgpVerify">
<i class="fontastic">🔒</i>
<span data-i18n="MESSAGE/PGP_SIGNED_MESSAGE_DESC"></span>
</div>
<div class="bodyText"
data-bind="initDom: messagesBodiesDom"></div>
</div>