Sende HTML-mails med billeder
At sende HTML-mails er nemt. Man skal bare sætte Content-Type til text/html. Skal man inkludere billeder i ens mail linker man bare til den på en server (<img src="http://www ..." alt=""/>). Alternativt kan man vedhæfte billederne.
Om man gør det ene eller det andet har nogle fordele og ulemper.
Når man linker til billeder
1) Man kan se om brugeren har åbnet ens mail ved at indsætte noget uniks i adressen til billederne.
2) Ens e-mails fylder mindre.
3) Man kan ændre billederne efter man har sendt e-mailen.
Når man vedhæfter billeder
1) Brugeren bliver typisk spurgt om mail-klienten skal vise billederne.
2) Ens e-mails fylder mere.
3) Brugeren har ikke fysisk modtaget hele mailen og visningen afhænger af mange ting. Er brugeren online, er serveren oppe, ligger billederne der stadig osv.
Jeg vil typisk være fortaler for at vedhæfte billederne. Det er dog lidt mere kompliceret. Fremgangsmåden er i grove træk følgende:
Fælgende headere skal sættes:
1 2 | Content-Type: multipart/mixed; boundary="-----boundary-123" |
Hvor man selv vælger hvad boundary skal være. Det skal bare være unikt.
Efterfølgende skal indholdet af ens mail splittes op i stykker der adskilles af det man har valgt som boundary. Bemærk at der tilføjes to ekstra -- til starten af ens boundary, og at der tilføjes endnu to til slutningen af den sidste.
Føste stump er HTML-dokumentet. Bemærk værdien i src.
1 2 3 4 | -------boundary-123 Content-Type: text/html <img src="cid:img123.png"> |
Sidste del af e-mailen er en base64-encodet version af billedet. For at kæde billedet sammen med img-tag’et skal Content-ID matche. Dog uden < og >.
5 6 7 8 9 10 11 12 | -------boundary-123 Content-Type: image/png Content-Transfer-Encoding: base64 Content-ID: <img123.png> [DATA] -------boundary-123-- |
Ovenstående er lidt simplificeret. Normalt ville jeg inkludere nogle flere ting. Charset m.m. Men det illustrerer godt den overordnede fremgangsmåde.
Excel m.m. i PHP
I forlængelse af mit forrige indlæg med Zip-filer i PHP kan jeg nævne at de nye Office-produkter faktisk gemmer zip-filer med xml-dokumenter. Prøv fx at omdøbe en .xlsx-fil til .zip og pak den ud.
Jeg vil ikke lige gennemgå hvordan det er bygget op, men jeg har en fil ved navn xl\worksheets\sheet1.xml liggende, og den indeholder bl.a.:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | <sheetData> <row r="1" spans="1:2"> <c r="A1"> <v>100</v> </c> <c r="B1"> <v>400</v> </c> </row> <row r="2" spans="1:2"> <c r="A2"> <v>200</v> </c> <c r="B2"> <v>800</v> </c> </row> <row r="3" spans="1:2"> <c r="A3"> <f>SUM(A1:A2)</f> <v>300</v> </c> <c r="B3"> <f>SUM(B1:B2)</f> <v>1200</v> </c> </row> </sheetData> |
Det er en xml-repræsentation af følgende rækker:
1 2 3 | 100 400 200 800 300 1200 |
Hvor den sidste række er en sum af de to ovenstående.
Har man først gennemskuet strukturen er det forholdsvist nemt at lave et PHP-script der kan skrive og læse Excel2007-dokumenter.
Word2007-dokumenter er opbygget på ca. samme måde.
Zip-filer i PHP
At kunne håndtere zip-filer er meget anvendeligt. Hvis man fx vil gi’ ens brugerne mulighed for at downloade deres data. Eller hvis man gerne vil give de samme brugere mulighed for at uploade flere filer uden at skulle uploade dem enkeltvis (og uden at bruge et flash-plugin).
I PHP er det forbavsende let. Det eneste det kræver er at zip-modulet er indlæst.
Har i eksemplet opretter jeg en lipsum.zip og tilføjer filen lipsum.txt til den. Bemærk at jeg kalder filen for zlipsum.txt i zip-filen:
1 2 3 4 5 | $zip = new ZipArchive(); if ($zip->open("lipsum.zip", ZipArchive::OVERWRITE)) { $zip->addFile("lipsum.txt", "zlipsum.txt"); $zip->close(); } |
Her ta’r jeg den samme lipsum.zip og pakker zlipsum.txt ud i samme bibliotek:
6 7 8 9 10 | $zip = new ZipArchive(); if ($zip->open("lipsum.zip")) { $zip->extractTo("./", "zlipsum.txt"); $zip->close(); } |
For at checke om zip-funktionerne er tilgængelige kan man lave en:
1 2 3 | if (class_exists("ZipArchive")) { // ... } |
Nem caching af statiske filer
Apache har et lækkert mod_expires modul der gør det muligt at cache bestemte filer via nogle retningslinier i fx .htaccess.
Typisk vil man jo gerne være interesseret i at cache billeder, ikoner, stylesheets, javascript m.m. så meget som muligt. Det gøres nemt ved at indsætte følgende i ens .htaccess:
1 2 3 4 5 6 | <IfModule mod_expires.c> ExpiresActive On ExpiresByType image/png "access 1 days" ExpiresByType application/javascript "access 1 days" ExpiresByType text/css "access 1 days" </IfModule> |
Linie 1 checker om moduler er tilgængelig, og nummer 2 aktiverer det.
I de efterfølgende linier angiver jeg en Content-Type og en levetid. I levetiden kan man bruge begreber som years, months, days, hours osv. Angiver man “access 1 days” som jeg har gjort sætter Apache-serveren to headere:
1 2 | Expires: Sun, 02 Nov 2008 21:39:27 GMT Cache-Control: max-age=86400 |
Hvor tidspunktet i Expires er en dag ind i fremtiden, og max-age er tilsvarende det antal sekunder et døgn varer.
Ugenumre i MySQL
Et lille fif som de færeste sikkert ikke tænker over: Når man arbejder med ugenumre er der mange forskellige definitioner. Den danske definition siger at uge 1 er den uge der har mere end tre dage i det nye år. Det betyder at hvis man laver en
1 | SELECT WEEK('2008-09-22 07:08:04'); |
Så får man 38, men d. 22. sep. 2008 ligger i uge 39. Derfor skal man kalde WEEK() med et parameter der angiver hvilken ugestandart man vil anvende. For Danmark er det 3. Man skal derfor lave sin forespørgsel som:
1 | SELECT WEEK('2008-09-22 07:08:04', 3); |
Hvilket også returnerer 39.
EXISTS i MySQL
Jeg har tit en en-til-mange datastruktur når jeg fx arbejder med ting der har vedhæftet billeder. Det kan fx være på BedsteVen.dk, hvor brugerne kan have flere billeder på deres dyr eller annoncer.
På forsiden af BedsteVen.dk viser jeg nogle tilfældige dyr, men jeg vil kun vise de dyr der indeholder billeder. Min struktur er noget lignende (meget simplificeret):
Først tabellen med dyrene:
1 2 3 4 5 | CREATE TABLE item ( itemid INT UNSIGNED NOT NULL AUTO_INCREMENT, name VARCHAR(255) NOT NULL, PRIMARY KEY (itemid) ) ENGINE = MYISAM; |
Så tabellen med billederne:
1 2 3 4 5 | CREATE TABLE image ( imageid INT UNSIGNED NOT NULL AUTO_INCREMENT, path VARCHAR(255) NOT NULL, PRIMARY KEY(imageid) ) ENGINE = MYISAM; |
Til sidst tabellen der binder dyr og billeder sammen:
1 2 3 4 5 | CREATE TABLE items_and_images ( itemid INT UNSIGNED NOT NULL, imageid INT UNSIGNED NOT NULL, PRIMARY KEY(itemid, imageid) ) ENGINE=MyISAM; |
Så hælder vi data i tabellerne:
1 2 | INSERT INTO item (itemid, name) VALUES (1, 'Alfa'), (2, 'Beta'), (3, 'Charlie'), (4, 'Delta'), (5, 'Echo'), (6, 'Foxtrot'); |
1 2 3 4 5 6 | INSERT INTO image (imageid, path) VALUES (1, 'images/001.png'), (2, 'images/002.png'), (3, 'images/003.png'), (4, 'images/004.png'), (5, 'images/005.png'), (6, 'images/006.png'), (7, 'images/007.png'), (8, 'images/008.png'), (9, 'images/009.png'), (10, 'images/010.png'); |
1 2 3 | INSERT INTO items_and_images (itemid, imageid) VALUES (1, 1), (1, 2), (1, 3), (1, 4), (2, 5), (2, 6), (3, 7), (3, 8), (3, 9), (3, 10); |
For at hente navnet ud fra item-tabellen på alle de rækker hvor den pågældende række var i items_and_images brugte jeg EXISTS, og SQL’en blev som følgende:
1 2 3 4 | SELECT name FROM item WHERE EXISTS (SELECT imageid FROM image LEFT JOIN items_and_images AS iai USING(imageid) WHERE iai.itemid = item.itemid); |
Hvilket gav navnene: Alfa, Beta og Charlie :-)
Fidusen ved EXISTS er, at den returnerer true hvis dens argument returnerer rækker. Argumentet til EXISTS skal derfor være en subselection.
Træer i MySQL
Jeg synes at træstrukture i MySQL er bøvlet. Her kommer lige en måde som jeg plejer at bruge for at løse problemet:
Først kommer tabellen. Det er en klassisk struktur med en id, og en parentid der peger på id’en:
1 2 3 4 5 6 7 8 | CREATE TABLE tree ( treeid INT(10) UNSIGNED NOT NULL auto_increment, parentid INT(10) UNSIGNED NOT NULL, name VARCHAR(255) NOT NULL, PRIMARY KEY (treeid), UNIQUE KEY name (name), KEY (treeid, parentid) ) ENGINE=MyISAM; |
Så noget data:
1 2 3 4 5 | INSERT INTO tree (treeid, parentid, name) VALUES (1, 0, 'Danmark'), (2, 1, 'Fyn'), (3, 1, 'Jylland'), (4, 1, 'Sjælland'), (5, 2, 'Odense'), (6, 2, 'Otterup'), (7, 2, 'Nyborg'), (8, 2, 'Svendborg'), (9, 3, 'Esbjerg'), (10, 3, 'Vejle'), (11, 4, 'København'); |
Måden jeg henter det ud på er lidt speciel, og skalerer ikke hvis man har vilkårlig mange niveauer. Jeg vedtager her at jeg kun har fire niveauer.
Jeg JOIN’er tabellen på sig selv lige så mange gange som jeg har vedtaget at der er niveauer. Det er fire gange her i eksemplet. Jeg ville selvfølgelig lave et script til at generere SQL’en.
1 2 3 4 5 6 7 8 9 | SELECT T0.name AS name0, T1.name AS name1, T2.name AS name2, T3.name AS name3 FROM tree AS T0 LEFT JOIN tree AS T1 ON T1.parentid = T0.treeid LEFT JOIN tree AS T2 ON T2.parentid = T1.treeid LEFT JOIN tree AS T3 ON T3.parentid = T2.treeid WHERE T0.treeid = 1; |
Det giver følgende resultat når man i sin WHERE sætter treeid til 1 — man kan selvfølgelig sætte den til hvad man synes afhængig af hvor i træet man vil starte fra:
Danmark Fyn Odense NULL Danmark Fyn Otterup NULL Danmark Fyn Nyborg NULL Danmark Fyn Svendborg NULL Danmark Jylland Esbjerg NULL Danmark Jylland Vejle NULL Danmark Sjælland København NULL
Så kan man så via fx PHP fortolke det som man vil …
Ovenstående kan også laves som et view. Hvilket nok går det nemmere at arbejde med:
1 2 3 4 5 6 7 8 9 10 | CREATE VIEW treeview AS SELECT T0.name AS name0, T1.name AS name1, T2.name AS name2, T3.name AS name3 FROM tree AS T0 LEFT JOIN tree AS T1 ON T1.parentid = T0.treeid LEFT JOIN tree AS T2 ON T2.parentid = T1.treeid LEFT JOIN tree AS T3 ON T3.parentid = T2.treeid WHERE T0.treeid = 1; |
Det er måske ikke en helt køn løsning, men den virker …
Toplister med views
Det er tit jeg har en struktur hvor jeg har en tabel med fx annoncer, og så en tabel der gemmer antal visninger. En måde hvorpå man nemt kan arbejde med sin annonce-tabel og samtidig have let adgang til antal visninger er ved at oprette et VIEW.
Først opretter jeg min annonce-tabel:
1 2 3 4 5 | CREATE TABLE item ( itemid INT(10) unsigned NOT NULL auto_increment, name VARCHAR(255) NOT NULL, PRIMARY KEY (itemid) ) ENGINE=MyISAM; |
Så smider jeg lidt data i (ja, det er meget simple annoncer):
1 2 | INSERT INTO item (itemid, name) VALUES (1, 'Peter'), (2, 'Bente'), (3, 'Hans'), (4, 'Grete'); |
Så laver jeg en tabel til at gemme visninger. Jeg gemmer itemid og tidspunktet for visningen:
1 2 3 4 5 | CREATE TABLE item_views ( itemid INT(10) unsigned NOT NULL, viewtime DATETIME NOT NULL, KEY itemid (itemid, viewtime) ) ENGINE=MyISAM; |
Så smider jeg nogle visninger i:
1 2 3 4 5 6 | INSERT INTO item_views (itemid, viewtime) VALUES (1, '2008-09-18 15:06:16'), (1, '2008-09-18 15:06:24'), (1, '2008-09-18 15:06:29'), (1, '2008-09-18 15:06:36'), (1, '2008-09-18 15:06:37'), (3, '2008-09-18 15:06:45'), (3, '2008-09-18 15:06:46'), (3, '2008-09-18 15:06:47'), (4, '2008-09-18 15:06:51'), (4, '2008-09-18 15:06:58'); |
Til sidst kommer mit VIEW
1 2 3 4 5 6 | CREATE VIEW myview AS SELECT item.*, COUNT(item_views.itemid) AS total_views FROM item LEFT JOIN item_views USING (itemid) GROUP BY itemid ORDER BY itemid ASC; |
Tricket med mit VIEW er, at jeg laver en “SELECT item.*” så jeg får hele item-tabellen (annoncer). Så kombinerer jeg det med en COUNT. Jeg får derfor hele item-tabellen og antallet af visninger ud på samme tid.
Bemærk at jeg laver min COUNT på item_views.itemid for at undgå at item-rækken tæller med hvis den ikke optræder i item_views-tabellen.
Laver man nu en:
1 | SELECT * FROM myview; |
Får man følgende:
itemid, name, total_views
1, Peter, 5
2, Bente, 0
3, Hans, 3
4, Grete, 2
untitled.bmp I Internet Explorer
Jeg har døjet med et irriterende problem …
Jeg genererer nogle grafer ved hjælp af PEAR’s Image_Graph. Jeg ville gerne have at brugernes browsere foreslog et filnavn der gav mening når de valgte at gemme graferne. Endvidere ville jeg gerne have at brugerne kunne printe graferne. Første problem løste jeg ved at sætte følgende header:
Content-Disposition: inline; filename="mingraf.png"Som sidebemærkning kan det nævnes at den version jeg brugte af Image_graph sætter den header selv når man kalder $Graph->done(), men den sætter den forkert. Skal man bruge den header, skal man selv sætte den.
Det fungerer også fint i Firefox, men i Internet Explorer* (må den brænde i helvede!) valgte den altid at gemme billedet som untitled.bmp, og IE kunne heller ikke printe grafen. Ved print opførte den sig som den gør hvis billedet ikke findes.
Så opgaven var 1) filnavn og 2) print
Løsningen blev 1) tillad caching 2) undgå cookies
Dog skal man være opmærksom på, at hvis man kun tillader eller opfordre til caching vil IE gemme grafen som untitled.bmp såfrem man har prøvet at printe eller brugt “Vis udskrift”.
Hvordan man implementerer det er op til en selv, men man skal undgå at sende cookies, samt undgå headere der indikerer at siden ikke må caches. Der blev fra PHP’s side sendt en Cache-Control: no-cache. Den overskrev jeg til sidst i mit script til at sende en Cache-Control: cache.
Sendes der andre caching-begrænsende headere skal disse ligeledes overskrives af headere der tillader eller opfordre til caching. Umiddelbart er jeg kun stødt på Cache-Control, Pragma samt Expires.
Min motivation for at grafen oprindeligt skulle sende en cookie bunder i, at man kun skulle have adgang til den hvis man var logget ind. Derfor inkluderede alle mine script noget koder der kontrollerede brugeren, hvorfor den også satte en cookie hvis man havde adgang.
*) Jeg har kun testet det i Internet Explorer 7.0.5730.13

Jeg hedder Morten, og jeg har udviklet webapplikationer siden slutningen af 90'erne.