SPRÁVNÉ ŘEŠENÍ PRO: Přímé stažení souboru v NETTE pomocí redirectu formuláře

Již dříve jsem nastínil jak na to. Ale to bylo řešení postavené na dříve osvojených principech (si všechno napsat sám) a až nyní jsem našel způsob, jak to napsat elegantněji pomocí NETTE.

Presenter:

< ?php
 
class PcClientPresenter extends BasePresenter
{
	protected function createComponentForm($name) {
		$form = new AppForm($this, $name);
		$form->addPassword('heslo', 'Heslo: ')
			->addRule(Form::FILLED, 'Heslo musí být vyplněné.');
		$form->addSubmit('send', 'Stáhnout soubor »');
 
		$form->onSubmit[] = array($this, 'formSubmitted');
	}
 
	public function formSubmitted($form) {
		$file = WWW_DIR . '/data/soubor.zip'; // soubor může být úplně mimo web root (=nestáhnutelný pomocí URL)
		$fileName = 'Soubor po stažení.zip'; // název pod kterým se bude soubor stahovat uživateli
 
		$arr = $form->getValues();
		if ($arr['heslo'] === 'HESLO') {
			$httpResponse = Environment::getHttpResponse();
			$httpResponse->setHeader('Pragma', "public");
			$httpResponse->setHeader('Expires', 0);
			$httpResponse->setHeader('Cache-Control', "must-revalidate, post-check=0, pre-check=0");
			$httpResponse->setHeader('Content-Transfer-Encoding', "binary");
			$httpResponse->setHeader('Content-Description', "File Transfer");
			$httpResponse->setHeader('Content-Length', filesize($file));
			$this->sendResponse(new DownloadResponse($file, $fileName, array('application/octet-stream', 'application/force-download', 'application/download')));
		} else {
			$this->flashMessage('Nesouhlasí zadané heslo.', 'error');
		}
	}
 
	public function actionDefault() {}
}

Pro pořádek připojuji i obsah šablony:

{block #content}
<h1>Stažení PC klienta</h1>
    <div class="flash {$flash->type}">{$flash->message}</div>
    {control form}
{/block}

Jak neotravovat uživatele s antispamem?

Známe to všichni. Chceme se registrovat na nějaký web, jít do internetového bankovnictví, odeslat objednávku v eshopu, přispět do diskuze a vyskočí na nás jen těžko čitelná slátanina různě pokrouceného textu. Ano první popisovaná možnost je dneska velmi oblíbená captcha.
Captcha

A světe div se. Ve většině případů je toto řešení zbytečné a příliš obtěžující… Jaké má dneska vývojář další možnosti? Existuje možnost zabránit SPAMu a zároveň, aby to uživatele nijak neobtěžovalo? Zkusím projít dneska běžně používané řešení a pak odpovědět na položené otázku.

Polidštěné antispamy, vyžadující interakci uživatele

Vyžádání si opisu textu:
Antispam opis textu
Vybrání odpovídající možnosti:
Antispam výběr dne.
Vybrání odpovídajícího obrázku:
Antispam select images
Nicméně tyto antispamy opět vyžadují spolupráci uživatele a s nadsázkou i jisté znalosti.

Další metodou je na komentáře nasadit nějaký klasický antispam, jaký známe například z emailů. Tuto možnost využívá například i plugin Akismet pro publikační systém WordPress (používá ho i tento blog). Tento systém funguje na principu, že nebrání spamovacím robotům v tom příspěvek založit (což chválím – uživateli nehází klacky pod nohy), ale následnou analýzou se snaží identifikovat, zda příspěvek zobrazí či ne. Tady je ale na druhou stranu potřebná interakce administrátora, což může být na serveru, který je v centru dění dost obtěžující.
Wordpress Akismet

Dokonalé antispamové řešení
Využijeme základu z prvního polidštěného příkladu:
Antispam opis textu
HTML:

<p id="antispam">
    Ochrana proti spamu. Napište prosím číslo dvacet-čtyři: 
    <input type="text" name="robot" value="" id="antispaminput"/>
</p>

Ale doplníme to následujícím Javascriptovým kódem:

document.getElementById("antispaminput").value = "24";
document.getElementById("antispam").style.display = "none";

případně v jQuery:

$("antispaminput").val(24);
$("antispam").css('display', 'none');

ten způsobí, že se pole automaticky vyplní a zároveň skryje. Uživatel má pocit, že žádný antispam neřeší, ale na pozadí ho za něho řeší prohlížeč. Pokud ale uživatel (prohlížeč) nepodporuje Javascript (vlastnost webových robotů), tak mu políčko zůstane zobrazeno a musí touto kontrolou projít.

Proč tedy je tak moderní „otravovat“ uživatele luštěním a vyplňováním pokřivených znaků?

Přímé stažení souboru v NETTE pomocí redirectu formuláře

Potřeboval jsem udělat jednoduché stahovadlo souboru. Požadavky byly, aby pro stažení se muselo zadat heslo a aby soubor nešlo ani následně stahovat přes nějakou konstantní URL.

Níže uváděné řešení již považuji za zastaralé a nechávám ho tady jen aby bylo možné porovnat, že i toto jde v NETTE napsat lépe. Pokračujte zde…


Řešení je nakonec triviální:
Presenter:

< ?php class PcClientPresenter extends BasePresenter {
 	protected function createComponentForm($name) {
 		$form = new AppForm($this, $name);
 		$form->addPassword('heslo', 'Heslo: ')
			->addRule(Form::FILLED, 'Heslo musí být vyplněné.');
		$form->addSubmit('send', 'Stáhnout soubor »');
 
		$form->onSubmit[] = array($this, 'formSubmitted');
	}
 
	public function formSubmitted($form) {
		$file = WWW_DIR . '/data/soubor.zip'; // soubor může být úplně mimo web root (=nestáhnutelný pomocí URL)
		$fileName = 'Soubor po stažení.zip'; // název pod kterým se bude soubor stahovat uživateli
 
		$arr = $form->getValues();
		if ($arr['heslo'] === 'HESLO') {
			$httpResponse = Environment::getHttpResponse();
			// Pošleme prohlížeči hlavičky pomocí kterých mu řekneme že se jedná o stahování
			$httpResponse->setContentType('application/octet-stream');
			$httpResponse->setContentType('application/force-download');
			$httpResponse->setContentType('application/download');
			$httpResponse->setHeader('Pragma', "public");
			$httpResponse->setHeader('Expires', 0);
			$httpResponse->setHeader('Cache-Control', "must-revalidate, post-check=0, pre-check=0");
			$httpResponse->setHeader('Content-Transfer-Encoding', "binary");
			$httpResponse->setHeader('Content-Description', "File Transfer");
			$httpResponse->setHeader('Content-Length', filesize($file));
			$httpResponse->setHeader('Content-Disposition', 'attachment; filename="' . $fileName . '"');
 
			// v cyklu načítáme postupně soubor a posíláme ho prohlížeči (u malých souborů by to šlo najednou ale u velkých by došla dostupná paměť pro PHP)
			$fp = fopen($file, 'r');
			while (!feof($fp)) {
				echo fread($fp, 65536);
				flush();
			}
			fclose($fp);
			$this->terminate();
		} else {
			$this->flashMessage('Nesouhlasí zadané heslo.', 'error');
		}
	}
 
	public function actionDefault() {}
}

Template:

{block #content}
<h1>Stažení PC klienta</h1>
<div class="flash {$flash->type}">{$flash->message}</div>
{control form}
{/block}


Vše!

Jak se odkazovat na předchozí a následující příspěvek

V tomto příspěvku mi nejde o klasické stránkování, které se dá snadno vyřešit pomocí klauzule LIMIT v MySQL.
Nejde mi ani o seznamy, které se dají jednoduše a striktně seřadit podle velikosti (číslo, čas), ale jde mi o případ, kdy máme například seznam článků seřazených podle jména a chceme jednoduše přecházet na další či předchozí. Tady řazení podle operátorů <, > nebude to pravé.
Vyřešit se to dá následovně (vykopírováno z NETTE aplikace s použitou knihovnou DIBI):

// funkce zobrazuje objednávku dle ID, pokud ji pošleme parametr KAM, tak se zpracuje a funkce se zavolá znova jen s konkrétní ID
 
public function renderDetail($id, $kam = '') {
	if ($kam == '') {
		$this->template->rows = dibi::fetchAll('SELECT * FROM [objednavka_polozky] [o] JOIN [w_zbozi] USING ([kod_zbozi]) JOIN [objednavka_hlavicka] USING ([id_objednavka]) WHERE [id_objednavka] = %i GROUP BY [kod_zbozi]', $id);
	} elseif ($kam == 'PREV') {
		dibi::query('SET @poradi:=0, @poradinow:=0, @idprev:=0, @idnext:=0;');
		dibi::query('SELECT @poradi:=@poradi+1, IF(id_objednavka=%i, @poradinow:=@poradi, 0) FROM [objednavka_hlavicka] [oh] ORDER BY [cas] DESC', $id);
		dibi::query('SET @poradi:=0;');
		dibi::query('SELECT @poradi:=@poradi+1, IF(@poradinow-1=@poradi, @idprev:=id_objednavka, 0), IF(@poradinow+1=@poradi, @idnext:=id_objednavka, 0) FROM [objednavka_hlavicka] [oh] ORDER BY [cas] DESC');
		$row = dibi::fetch('SELECT @poradinow, @idnext AS next, @idprev AS prev;');
		if ($row['prev'] == 0) {
			$this->flashMessage('Nelze se již posunout na předchozí objednávku.', 'info');
			$this->redirect('Admin:detail', array('id' => $id));
		}
		$this->redirect('Admin:detail', array('id' => $row['prev']));
	} elseif ($kam == 'NEXT') {
		dibi::query('SET @poradi:=0, @poradinow:=0, @idprev:=0, @idnext:=0;');
		dibi::query('SELECT @poradi:=@poradi+1, IF(id_objednavka=%i, @poradinow:=@poradi, 0) FROM [objednavka_hlavicka] [oh] ORDER BY [cas] DESC', $id);
		dibi::query('SET @poradi:=0;');
		dibi::query('SELECT @poradi:=@poradi+1, IF(@poradinow-1=@poradi, @idprev:=id_objednavka, 0), IF(@poradinow+1=@poradi, @idnext:=id_objednavka, 0) FROM [objednavka_hlavicka] [oh] ORDER BY [cas] DESC');
		$row = dibi::fetch('SELECT @poradinow, @idnext AS next, @idprev AS prev;');
		if ($row['next'] == 0) {
			$this->flashMessage('Nelze se již posunout na následující objednávku.', 'info');
			$this->redirect('Admin:detail', array('id' => $id));
		}
		$this->redirect('Admin:detail', array('id' => $row['next']));
	} else {
		//chyba
		$this->redirect('Admin:objednavky');
	}
}

Příklad není úplně přehledný, protože jsem ho vykopíroval z hotové aplikace a aplikační logika není úplně čistá, ale pro demonstraci to doufám stačí.

Automatické dotahování údajů o podnikatelích z databáze ARES

Někde na internetu jsem viděl jak po zadání IČ se zbytek údajů o živnostníkovi/firmě dotáhl sám. A jelikož jsem si chtěl o tuto možnost rozšířit i své personální účetnictví, tak jsem pátral jak na to.

O databázi ARES jsem se dozvěděl již dříve, ale nikdy jsem se nedostal k samotné implementaci. Až dneska jsem narazil na článek Radka Hulána, řešící přesně toto a tak jsem si řekl, že se na to podívám.

Zdroj dat: Databáze ARES
Script: MyEGO blog

Script po úpravě na straně serveru (jedná se o NETTE akci):

public function handleLoadInfo($IC) {
	$this->payload->firma = array();
    	// dá se vybrat hned z několika zdrojů dle potřeby http://wwwinfo.mfcr.cz/ares/ares_xml.html.cz#k3
	define('ARES','http://wwwinfo.mfcr.cz/cgi-bin/ares/darv_bas.cgi?ico=');
	$ico = intval($IC);
	// nemohl jsem použít kvůli omezení na serveru, nahradil jsem pomocí CURL
	//$file = @file_get_contents(ARES.$ico);
	if ($curl = curl_init(ARES.$ico)) {
		curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
		$content = curl_exec($curl);
		//$info = curl_getinfo($curl);
		curl_close($curl);
		$xml = @simplexml_load_string($content);
	}
	$a = array();
	if (isset($xml)) {
		$ns = $xml->getDocNamespaces();
		$data = $xml->children($ns['are']);
		$el = $data->children($ns['D'])->VBAS;
		if (strval($el->ICO) == $ico) {
			$a['ico'] 	= strval($el->ICO);
			$a['dic'] 	= strval($el->DIC);
			$a['firma'] = strval($el->OF);
			$a['ulice']	= strval($el->AA->NU).' '.strval($el->AA->CD).'/'.strval($el->AA->CO);
			$a['mesto']	= strval($el->AA->N).'-'.strval($el->AA->NCO);
			$a['psc']	= strval($el->AA->PSC);
			$a['stav'] 	= 'ok';
		} else {
			$a['stav'] 	= 'IČ firmy nebylo nalezeno';
		}
	} else {
		$a['stav'] 	= 'Databáze ARES není dostupná';
	}
	$this->payload->firma = $a;
	$this->sendPayload();
}

Script po úpravě na straně uživatele:

<script type="text/javascript">
    <!--
	$('#frmkontakt-IC').change(function(event) {
		$.getJSON({link loadInfo!}, {'IC': $('#frmkontakt-IC').val()}, function(payload) {
			if (payload.firma.stav == 'ok') {
				$('input[name=DIC]').val(payload.firma.dic);
				$('input[name=prijmeni]').val(payload.firma.firma);
				$('input[name=ulice]').val(payload.firma.ulice);
				$('input[name=mesto]').val(payload.firma.mesto);
				$('input[name=psc]').val(payload.firma.psc);
			} else {
				alert(payload.firma.stav);
			}
		});
	});
	-->
</script>

Chtělo by to ještě doladit o podmínky kdy co a jak vypisovat, spojovat (číslo orientační a popisné, město a čtvrť, atd.), ale jako referenční implementace to je dobrý základ.

Zálohování databáze mysql a souborů na linuxu

Po delší době jsem se díky velikonocům dostal ke správě linuxového stroje. Již delší dobu jsem věděl, že by to chtělo nějak lépe vyřešit zálohování. Ne že by nefungovalo, ale řešení bylo poplatné době kdy vznikalo a také lidem, kteří ho implementovali.

A nakonec to nebylo ani tak těžké…

Záloha MySQL databází

Požadavky

  • denní zálohu držet 7 dní
  • týdenní zálohu držet 4 týdny
  • měsíční zálohu držet 3 měsíce
  • zálohovat každý web zvlášť
  • komprimovat zálohy

Použité prostředky

  • mysqldump
  • cron
  • scriptovací jazyk perl

Implementace

Script backup_day_mysql.pl

#!/usr/bin/perl -w
# vytáhneme si seznam jednotlivých databází, krom systémových
my @seznam = `echo "show databases" | mysql -uUSER_NAME -pPASSWORD | egrep -v '^(Database|information_schema|mysql)\$'`;
# připravíme si proměnou obsahující datum, pro pozdější název souboru
my $datum = `date +\%Y-\%m-\%d_\%H-\%M`;
$datum =~ s/\n//;
# každou databázi zkomprimujeme, pojmenujeme a uložíme do adresáře záloh
foreach my $radek (@seznam) {
  $radek =~ s/\n//;
  my $out = `mysqldump -uUSER_NAME -pPASSWORD $radek | bzip2 -c9 > /var/backups/day/mysql/$datum-$radek.bz2`;
}

Automatické spouštění
Toto zajistíme CRONem. CRON upravujeme po zadání příkazu crontab -e. Více v manuálu.

5 3 * * *  /home/backup_day_mysql.pl

Záloha souborů s weby

Požadavky

  • denní zálohu držet 7 dní
  • týdenní zálohu držet 4 týdny
  • měsíční zálohu držet 3 měsíce
  • zálohovat každý web zvlášť
  • komprimovat zálohy

Použité prostředky

  • cron
  • scriptovací jazyk perl

Implementace

Script backup_day.pl

#!/usr/bin/perl -w
my @seznam = `ls -1 /var/www`;
my $datum = `date +\%Y-\%m-\%d_\%H-\%M`;
$datum =~ s/\n//;
foreach my $radek (@seznam) {
  $radek =~ s/\n//;
  my $out = `tar -cvzf /var/backups/day/www/$datum-$radek.tgz /var/www/$radek`;
}

Automatické spouštění
Toto zajistíme opět CRONem.

10 3 * * * /home/backup_day.pl

Automatický úklid

Pokud bychom jen spouštěli tyto scripty, tak by velikost zabraného místa stále jen stoupala. A pokud provozujeme třeba web s 1GB daty, tak to máme každý den pěkný přírůstek.
Takže musíme po sobě i uklízet. To zajistíme pro denní zálohy dle požadavku příkazem:

# najdi všechny soubory starší než 7 dní a ty smaž
find /var/backups/day/www/ -type f -mtime +7 -delete

Opět spouštěným pravidelně v CRONu.

Pro denní, týdenní a měsíční zálohy to pak celé vypadá takto

CRON

# m h  dom mon dow   command
5 3 * * *  /home/backup_day_mysql.pl
10 3 * * * /home/backup_day.pl
15 3 * * * find /var/backups/day/www/ -type f -mtime +7 -delete
15 3 * * * find /var/backups/day/mysql/ -type f -mtime +7 -delete
20 3 * * 7 /home/bacup_mysql.pl
25 3 * * 7 /home/backup_week.pl
30 3 * * 7 find /var/backups/week/www/ -type f -mtime +28 -delete
30 3 * * 7 find /var/backups/week/mysql/ -type f -mtime +28 -delete
35 3 1 * * /home/backup_month_mysql.pl
40 3 1 * * /home/backup_month.pl
45 3 1 * * find /var/backups/month/www/ -type f -mtime +90 -delete
45 3 1 * * find /var/backups/month/mysql/ -type f -mtime +90 -delete

Know-how, které jsem v článku pro zjednodušení zamlčel

  • Soubory ukládám do /var/backups/{DRUH_ZALOHY}/{CO_ZALOHUJI}/ (DRUH_ZALOHY – day, week, month; CO_ZALOHUJI – www, mysql). Netvrdím, že to je jedniné možné a správné řešení, ale umožňuje se mi celkem snadno ve struktuře orientovat
  • /var/backups mám namapované na samostatný oddíl (/dev/sda6) což brání, aby díky zálohování došlo místo na primárním disku a tím to shodilo systém (zálohy ale v tuto dobu nepoběží). Na toto narazilo i mnoho profesionálních hostingů.
  • Scriptům je třeba dát právo na spuštění (např. chmod +x /home/backup_day.pl)
  • CRON je třeba nastavit tak, aby se nejlépe jednotlivé probíhající zálohy neovlivňovaly a zbytečně se nesnižoval výkon systému.
  • I přes tento sytém je třeba občas kontrolovat, zda zálohy probíhají korektně.
  • Je dobré zálohy občas stáhnout a vypálit či uložit geograficky dále od serveru.

Co se dá ještě vylepšit?

  • Mailová notifikace o (ne)provedení zálohy.
  • Automatický upload záloh mimo server (FTP, SCP).
  • Tvorba jen inkrementálních záloh.
  • Test správného vytvoření souborů.
  • Optimalizace příkazu mysqldump o doprovodné atributy.

Chyba „e100/d101m_ucode.bin“ při instalaci linuxové distribuce Debian Squezze

Jedná se o chybu, kdy nějaký hardware vyžaduje nesvobodný firmware. Smůla je, když je tímto HW síťová karta a tudíž si instalátor sám není schopen potřebný (firmware-linux-nonfree) balíček stáhnout.

Ani google moc nepomohl v otázce, kde si ho stáhnout a stahovat 52 instalačních CD se mi fakt nechtělo…

Řešením je stáhnout instalační CD, které právě tento firmware-linux-nonfree balíček obsahuje. To se dá provést přímým linkem na který jsem se dostal odtud.

Po stažení a nabootování z tohoto CD mě již nic nestálo v cestě a instalace proběhla bez problému.

Jak na potvrzení registrace v NETTE?

U jednoho dříve zveřejněného příspěvku (Registrace uživatelů pomocí NETTE) se objevil komentář žádající vysvětlení, jak dále zpracovávat potvrzující odkaz z došlého mailu.
Pro nevytržení z kontextu zopakuji část, která takový odkaz tvoří a odesílá e-mail. Kód jsem upravil o dynamickou tvorbu odkazu, jak jsem byl upozorněn v diskuzi u dříve zmíněného článku.

// vytvoření kontrolního hashe
$kontrolni_link = sha1($values['nick'] . time() . 'e743dd075ff52c2');
 
unset($values['pass2']); // druhé heslo neukládáme
$values['retik'] = $kontrolni_link;
dibi::query('INSERT INTO [user]', $values);
 
#mail pro kontrolu
$mail = new Mail;
$mail->setFrom('Registrace na webu xxx.com <info @xxx.com>');
$mail->addTo($values['email']);
$mail->setSubject('Registrace na webu xxx.com');
$mail->setBody("Děkujeme za Váši registraci. Potvrďte prosím na adrese " . $this->link('//Registrace:kontrola', array('kontrola'=>$kontrolni_link)) . ".");
$mail->send();
// zase mi tu WP zlobí dále už nic nemá být ;-)
</info>

Toto je jen jedna možnost jak kontrolu dělat. Díky způsobu tvorby (používáme funkci time() a její hodnotu si nikam dále neukládáme) kontrolního HASHe, je nutné ho uložit do databáze a následně při kontrole ho testovat zda odpovídá. Jen pro informaci zmíním, že je tu i další možnost tvorby a to, že si odkazem pošleme více parametrů, díky kterým budeme schopni registraci potvrdit, bez dopomocí databáze (např.: $this->link(‚//Registrace:kontrola‘, array(‚nick’=>$values[‚nick‘], ‚kontrola’=>sha1($values[‚nick‘] . ‚e743dd075ff52c2‘)))).

A nyní samotné MINIMALISTICKÉ (=jedná se o koncept, ne o deklaraci dokonalého zpracování) zpracování (budu se držet první možnosti):

if (empty ($_GET['kontrola'])) {
	die('CHYBA!');
} else {
	$kod = $_GET['kontrola'];
	dibi::query('UPDATE [user] SET [retik]="" WHERE [retik]=%s', $kod);
 
	if (dibi::affectedRows() == 1) {
		echo 'Registrace dokončena.';
	} else {
		echo 'Registraci se nepodařilo dokončit.';
	}
}

Nutno podotknout, že při přihlašování mám podmínku na nevyplněnost sloupce retik v tabulce user.

...
$row = dibi::fetch('SELECT [id], [nick], [pass], [retik], [role] FROM [user] WHERE [nick]=%s', $username);
 
if ($row->retik !== '') {
    throw new AuthenticationException("Registrace nebyla dosud potvrzena.", self::NOT_APPROVED);
}
...

Celá ukázka nevyužívá plné síly NETTE. Ukázka vznikla jako návaznost na původní článek, kde se NETTE použilo v již hotové aplikaci. Doufám ale, že postup je z popisu jasný a přepsání do plného NETTE nebude činit problém.

Validní náhrada target=“_blank“ v xHTML

Čím nahradit klasický odkaz s otevíráním do nového okna?

Validní zápis v HTML, ale nevalidní v xHTML :(

<a href="http://jankocmanek.cz/" target="_blank">jankocmanek.cz</a>

Já na toto používám JavaScript, lépe řečeno jQuery knihovnu, která smazává rozdíly mezi interpretací jednotlivých konstrukci napříč prohlížeči.

...
<script type="text/javascript" src="jquery-1.3.2.min.js"></script>
<script type="text/javascript">
$(function(){
    $("a[href^='http://']").click(function() {
        return!window.open(this.href,'_blank');
    });
});
</script>
...
<a href="http://jankocmanek.cz/">jankocmanek.cz</a>

Proč to vypadá takto se dozvíte v dokumentaci jQuery, sekce selectory.

Antispamová ochrana emailu proti robotům

Viděl jsem spousty řešení od nepěkných adres ve formátu „jmeno  <ZAVINAC> domena <TECKA> cz“ nebo kde místo zavináče byl obrázek či vypsání celého mailu pomocí GD knihovny do obrázku a další technoidní masturbace.

Já mám zkušenost, že stačí zavináč a tečku vyměnit za entitu a zatím jsem na žádném projektu neměl problémy. Jasně netvrdím, že to je dokonalé řešení, ale pro 99% vývojářů v PHP je toto řešení dostatečné.

Funkce pro ošetření mailu vypadá takto:

function safeEmail ($email) {
    return str_replace(array('@', '.'), array('&#64;', '&#46;'), $email);
}

Použití:

$email = safeEmail($email);
echo "<a href='mailto:{$email}'>{$email}</a>";

Krásně jednoduché a zároveň funkční bez dopadu na uživatelský komfort.