Skip to content
Snippets Groups Projects
Commit 29b37144 authored by Jared Hancock's avatar Jared Hancock
Browse files

Remove requirement of ticket id in subject line

This patch affords an administrator the ability to remove the
[#%{ticket.number}] from the email template subject line for the new ticket
autoresponse and the new message autoresponse. Previously, the ticket number
with a prefixed hash in brackets was used to identify which ticket thread an
email was in reference to.

With this patch, the email message-id (which was already kept on file) is
sent in the MIME "References" header. When a user responds to and
autoresponse email, the "References" will include this message-id in the
return email. The ticket thread is then matched up with the email based on
the message-id rather than the subject line.

Ticket numbers are still supported in the subject line, in the event that
non-compliant email clients do not properly include the References header.
parent acd4f06d
Branches
Tags
No related merge requests found
......@@ -113,6 +113,10 @@ class TicketApiController extends ApiController {
return $ticket;
}
if (($thread = ThreadEntry::lookupByEmailHeaders($data))
&& $thread->postEmail($data)) {
return true;
}
return $this->createTicket($data);
}
......
......@@ -423,11 +423,6 @@ class ApiEmailDataParser extends EmailDataParser {
if(!$data['emailId'])
$data['emailId'] = $cfg->getDefaultEmailId();
if($data['email'] && preg_match ('[[#][0-9]{1,10}]', $data['subject'], $matches)) {
if(($tid=Ticket::getIdByExtId(trim(preg_replace('/[^0-9]/', '', $matches[0])), $data['email'])))
$data['ticketId'] = $tid;
}
if(!$cfg->useEmailPriority())
unset($data['priorityId']);
......
......@@ -123,6 +123,18 @@ class Mailer {
$headers+= array('Precedence' => 'auto_reply');
}
if ($options) {
if (isset($options['replyto']))
$headers += array('In-Reply-To' => $options['replyto']);
if (isset($options['references'])) {
if (is_array($options['references']))
$headers += array('References' =>
implode(' ', $options['references']));
else
$headers += array('References' => $options['references']);
}
}
$mime = new Mail_mime();
$mime->setTXTBody($body);
//XXX: Attachments
......
......@@ -233,6 +233,8 @@ class MailFetcher {
'subject'=>@$headerinfo->subject,
'mid' => trim(@$headerinfo->message_id),
'header' => $this->getHeader($mid),
'in-reply-to' => $headerinfo->in_reply_to,
'references' => $headerinfo->references,
);
if ($replyto = $headerinfo->reply_to) {
......@@ -400,10 +402,6 @@ class MailFetcher {
return true; //Report success (moved or delete)
}
//Make sure the email is NOT already fetched... (undeleted emails)
if($mailinfo['mid'] && ($id=Ticket::getIdByMessageId($mailinfo['mid'], $mailinfo['email'])))
return true; //Reporting success so the email can be moved or deleted.
$vars = $mailinfo;
$vars['name']=$this->mime_decode($mailinfo['name']);
$vars['subject']=$mailinfo['subject']?$this->mime_decode($mailinfo['subject']):'[No Subject]';
......@@ -423,19 +421,15 @@ class MailFetcher {
$ticket=null;
$newticket=true;
//Check the subject line for possible ID.
if($vars['subject'] && preg_match ("[[#][0-9]{1,10}]", $vars['subject'], $regs)) {
$tid=trim(preg_replace("/[^0-9]/", "", $regs[0]));
//Allow mismatched emails?? For now NO.
if(!($ticket=Ticket::lookupByExtId($tid, $vars['email'])))
$ticket=null;
}
$errors=array();
if($ticket) {
if(!($message=$ticket->postMessage($vars, 'Email')))
return false;
if (($thread = ThreadEntry::lookupByEmailHeaders($vars))
&& ($message = $thread->postEmail($vars))) {
if ($message === true)
// Email has been processed previously
return true;
elseif ($message)
$ticket = $message->getTicket();
} elseif (($ticket=Ticket::create($vars, $errors, 'Email'))) {
$message = $ticket->getLastMessage();
} else {
......
......@@ -189,6 +189,14 @@ class Thread {
function delete() {
/* XXX: Leave this out until TICKET_EMAIL_INFO_TABLE has a primary
* key
$sql = 'DELETE mid.* FROM '.TICKET_EMAIL_INFO_TABLE.' mid
INNER JOIN '.TICKET_THREAD_TABLE.' thread ON (thread.id = mid.message_id)
WHERE thread.ticket_id = '.db_input($this->getTicketId());
db_query($sql);
*/
$res=db_query('DELETE FROM '.TICKET_THREAD_TABLE.' WHERE ticket_id='.db_input($this->getTicketId()));
if(!$res || !db_affected_rows())
return false;
......@@ -230,7 +238,7 @@ Class ThreadEntry {
if(!$id && !($id=$this->getId()))
return false;
$sql='SELECT thread.*, info.* '
$sql='SELECT thread.*, info.email_mid '
.' ,count(DISTINCT attach.attach_id) as attachments '
.' FROM '.TICKET_THREAD_TABLE.' thread '
.' LEFT JOIN '.TICKET_EMAIL_INFO_TABLE.' info
......@@ -309,6 +317,10 @@ Class ThreadEntry {
return $this->ht['ticket_id'];
}
function getEmailMessageId() {
return $this->ht['email_mid'];
}
function getTicket() {
if(!$this->ticket && $this->getTicketId())
......@@ -471,7 +483,76 @@ Class ThreadEntry {
return $str;
}
/**
* postEmail
*
* After some security and sanity checks, attaches the body and subject
* of the message in reply to this thread item
*
* Parameters:
* mailinfo - (array) of information about the email, with at least the
* following keys
* - mid - (string) email message-id
* - name - (string) personal name of email originator
* - email - (string<email>) originating email address
* - subject - (string) email subject line (decoded)
* - body - (string) email message body (decoded)
*/
function postEmail($mailinfo) {
// +==================+===================+=============+
// | Orig Thread-Type | Reply Thread-Type | Requires |
// +==================+===================+=============+
// | * | Message (M) | From: Owner |
// | * | Note (N) | From: Staff |
// | Response (R) | Message (M) | |
// | Message (M) | Response (R) | From: Staff |
// +------------------+-------------------+-------------+
if (!$ticket = $this->getTicket())
// Kind of hard to continue a discussion without a ticket ...
return false;
// Make sure the email is NOT already fetched... (undeleted emails)
elseif ($this->getEmailMessageId() == $mailinfo['mid'])
// Reporting success so the email can be moved or deleted.
return true;
$vars = array(
'mid' => $mailinfo['mid'],
'ticketId' => $ticket->getId(),
'poster' => $mailinfo['name'],
'origin' => 'Email',
'source' => 'Email',
'ip' => '',
'reply_to' => $this,
);
$body = $mailinfo['message'];
// Disambiguate if the user happens also to be a staff member of the
// system. The current ticket owner should _always_ post messages
// instead of notes or responses
if ($mailinfo['email'] == $ticket->getEmail()) {
$vars['message'] = $body;
return $ticket->postMessage($vars, 'Email');
}
elseif ($staff_id = Staff::getIdByEmail($mailinfo['email'])) {
$vars['staffId'] = $staff_id;
$poster = Staff::lookup($staff_id);
$errors = array();
$vars['note'] = $body;
return $ticket->postNote($vars, $errors, $poster);
}
// TODO: Consider security constraints
else {
$vars['message'] = sprintf("Received From: %s\n\n%s",
$mailinfo['email'], $body);
return $ticket->postMessage($vars, 'Email');
}
// Currently impossible, but indicate that this thread object could
// not append the incoming email.
return false;
}
/* Returns file names with id as key */
function getFiles() {
......@@ -495,8 +576,11 @@ Class ThreadEntry {
$sql='INSERT INTO '.TICKET_EMAIL_INFO_TABLE
.' SET message_id='.db_input($this->getId()) //TODO: change it to thread_id
.', email_mid='.db_input($vars['mid']) //TODO: change it to mid.
.', headers='.db_input($vars['header']);
.', email_mid='.db_input($vars['mid']); //TODO: change it to mid.
if (isset($vars['header']))
$sql .= ', headers='.db_input($vars['header']);
$this->ht['email_mid'] = $vars['mid'];
return db_query($sql)?db_insert_id():0;
}
......@@ -544,8 +628,56 @@ Class ThreadEntry {
)?$e:null;
}
/**
* Parameters:
* mailinfo (hash<String>) email header information. Must include keys
* - "mid" => Message-Id header of incoming mail
* - "in-reply-to" => Message-Id the email is a direct response to
* - "references" => List of Message-Id's the email is in response
* - "subject" => Find external ticket number in the subject line
*/
function lookupByEmailHeaders($mailinfo) {
// Search for messages using the References header, then the
// in-reply-to header
$search = 'SELECT message_id FROM '.TICKET_EMAIL_INFO_TABLE
. ' WHERE email_mid=%s ORDER BY message_id DESC';
if ($id = db_result(db_query(
sprintf($search, db_input($mailinfo['mid'])))))
return ThreadEntry::lookup($id);
foreach (array('mid', 'in-reply-to', 'references') as $header) {
$matches = array();
if (!isset($mailinfo[$header]) || !$mailinfo[$header])
continue;
// Header may have multiple entries (usually separated by
// semi-colons (;))
elseif (!preg_match_all('/<[^>@]+@[^>]+>/', $mailinfo[$header],
$matches))
continue;
foreach ($matches[0] as $mid) {
$res = db_query(sprintf($search, db_input($mid)));
while (list($id) = db_fetch_row($res)) {
if ($t = ThreadEntry::lookup($id))
return $t;
}
}
}
// Search for ticket by the [#123456] in the subject line
$subject = $mailinfo['subject'];
$match = array();
if ($subject && preg_match("/\[#([0-9]{1,10})\]/", $subject, $match))
// Return last message for the thread
return Message::lastByExtTicketId((int)$match[1]);
return null;
}
//new entry ... we're trusting the caller to check validity of the data.
function create($vars) {
global $cfg;
//Must have...
if(!$vars['ticketId'] || !$vars['type'] || !in_array($vars['type'], array('M','R','N')))
......@@ -562,6 +694,12 @@ Class ThreadEntry {
if(isset($vars['pid']))
$sql.=' ,pid='.db_input($vars['pid']);
// Check if 'reply_to' is in the $vars as the previous ThreadEntry
// instance. If the body of the previous message is found in the new
// body, strip it out.
elseif (isset($vars['reply_to'])
&& $vars['reply_to'] instanceof ThreadEntry)
$sql.=' ,pid='.db_input($vars['reply_to']->getId());
if($vars['ip_address'])
$sql.=' ,ip_address='.db_input($vars['ip_address']);
......@@ -584,6 +722,12 @@ Class ThreadEntry {
if($vars['cannedattachments'] && is_array($vars['cannedattachments']))
$entry->saveAttachments($vars['cannedattachments']);
// Email message id (required for all thread posts)
if (!isset($vars['mid']))
$vars['mid'] = sprintf('<%s@%s>', Misc::randCode(24),
substr(md5($cfg->getUrl()), -10));
$entry->saveEmailInfo($vars);
return $entry;
}
......@@ -630,6 +774,17 @@ class Message extends ThreadEntry {
&& $m->getId()==$id
)?$m:null;
}
function lastByExtTicketId($ticketId) {
$sql = 'SELECT thread.id FROM '.TICKET_THREAD_TABLE
.' thread JOIN '.TICKET_TABLE.' ticket ON (ticket.ticket_id = thread.ticket_id)
WHERE thread_type=\'M\' AND ticket.ticketID = '.db_input($ticketId)
.' ORDER BY thread.id DESC LIMIT 1';
if (($res = db_query($sql)) && (list($id) = db_fetch_row($res)))
return Message::lookup($id);
else
return null;
}
}
/* Response - Ticket thread entry of type response */
......
......@@ -733,6 +733,8 @@ class Ticket {
if(!$dept || !($email=$dept->getAutoRespEmail()))
$email =$cfg->getDefaultEmail();
$options = array('references'=>$message->getEmailMessageId());
//Send auto response - if enabled.
if($autorespond && $email && $cfg->autoRespONNewTicket()
&& $dept->autoRespONNewTicket()
......@@ -746,7 +748,8 @@ class Ticket {
if($cfg->stripQuotedReply() && ($tag=$cfg->getReplySeparator()))
$msg['body'] ="\n$tag\n\n".$msg['body'];
$email->sendAutoReply($this->getEmail(), $msg['subj'], $msg['body']);
$email->sendAutoReply($this->getEmail(), $msg['subj'],
$msg['body'], null, $options);
}
if(!($email=$cfg->getAlertEmail()))
......@@ -763,7 +766,8 @@ class Ticket {
//Alert admin??
if($cfg->alertAdminONNewTicket()) {
$alert = str_replace('%{recipient}', 'Admin', $msg['body']);
$email->sendAlert($cfg->getAdminEmail(), $msg['subj'], $alert);
$email->sendAlert($cfg->getAdminEmail(), $msg['subj'],
$alert, null, $options);
$sentlist[]=$cfg->getAdminEmail();
}
......@@ -779,7 +783,8 @@ class Ticket {
foreach( $recipients as $k=>$staff) {
if(!is_object($staff) || !$staff->isAvailable() || in_array($staff->getEmail(), $sentlist)) continue;
$alert = str_replace('%{recipient}', $staff->getFirstName(), $msg['body']);
$email->sendAlert($staff->getEmail(), $msg['subj'], $alert);
$email->sendAlert($staff->getEmail(), $msg['subj'], $alert,
null, $options);
$sentlist[] = $staff->getEmail();
}
......@@ -831,7 +836,7 @@ class Ticket {
db_query('UPDATE '.TICKET_TABLE.' SET isanswered=1,lastresponse=NOW(), updated=NOW() WHERE ticket_id='.db_input($this->getId()));
}
function onMessage($autorespond=true, $alert=true) {
function onMessage($autorespond=true, $message=null) {
global $cfg;
db_query('UPDATE '.TICKET_TABLE.' SET isanswered=0,lastmessage=NOW() WHERE ticket_id='.db_input($this->getId()));
......@@ -875,7 +880,11 @@ class Ticket {
if($cfg->stripQuotedReply() && ($tag=$cfg->getReplySeparator()))
$msg['body'] ="\n$tag\n\n".$msg['body'];
$email->sendAutoReply($this->getEmail(), $msg['subj'], $msg['body']);
if (!$message)
$message = $this->getLastMessage();
$options = array('references' => $message->getEmailMessageId());
$email->sendAutoReply($this->getEmail(), $msg['subj'], $msg['body'],
null, $options);
}
}
......@@ -893,7 +902,8 @@ class Ticket {
$assigner = $thisstaff?$thisstaff:'SYSTEM (Auto Assignment)';
//Log an internal note - no alerts on the internal note.
$this->logNote('Ticket Assigned to '.$assignee->getName(), $comments, $assigner, false);
$note = $this->logNote('Ticket Assigned to '.$assignee->getName(),
$comments, $assigner, false);
//See if we need to send alerts
if(!$alert || !$cfg->alertONAssignment()) return true; //No alerts!
......@@ -931,10 +941,12 @@ class Ticket {
//Send the alerts.
$sentlist=array();
$options = array('references' => $note->getEmailMessageId());
foreach( $recipients as $k=>$staff) {
if(!is_object($staff) || !$staff->isAvailable() || in_array($staff->getEmail(), $sentlist)) continue;
$alert = str_replace('%{recipient}', $staff->getFirstName(), $msg['body']);
$email->sendAlert($staff->getEmail(), $msg['subj'], $alert);
$email->sendAlert($staff->getEmail(), $msg['subj'], $alert,
null, $options);
$sentlist[] = $staff->getEmail();
}
}
......@@ -1140,7 +1152,7 @@ class Ticket {
/*** log the transfer comments as internal note - with alerts disabled - ***/
$title='Ticket transfered from '.$currentDept.' to '.$this->getDeptName();
$comments=$comments?$comments:$title;
$this->logNote($title, $comments, $thisstaff, false);
$note = $this->logNote($title, $comments, $thisstaff, false);
$this->logEvent('transferred');
......@@ -1180,10 +1192,12 @@ class Ticket {
$recipients[]= $manager;
$sentlist=array();
$options = array('references' => $note->getEmailMessageId());
foreach( $recipients as $k=>$staff) {
if(!is_object($staff) || !$staff->isAvailable() || in_array($staff->getEmail(), $sentlist)) continue;
$alert = str_replace('%{recipient}', $staff->getFirstName(), $msg['body']);
$email->sendAlert($staff->getEmail(), $msg['subj'], $alert);
$email->sendAlert($staff->getEmail(), $msg['subj'], $alert,
null, $options);
$sentlist[] = $staff->getEmail();
}
}
......@@ -1279,7 +1293,7 @@ class Ticket {
if(list($msg) = split($tag, $vars['message']))
$vars['message'] = $msg;
if($vars['ip'])
if(isset($vars['ip']))
$vars['ip_address'] = $vars['ip'];
elseif(!$vars['ip_address'] && $_SERVER['REMOTE_ADDR'])
$vars['ip_address'] = $_SERVER['REMOTE_ADDR'];
......@@ -1290,16 +1304,13 @@ class Ticket {
$this->setLastMsgId($message->getId());
if (isset($vars['mid']))
$message->saveEmailInfo($vars);
if(!$alerts) return $message; //Our work is done...
$autorespond = true;
if ($autorespond && $message->isAutoResponse())
$autorespond=false;
$this->onMessage($autorespond); //must be called b4 sending alerts to staff.
$this->onMessage($autorespond, $message); //must be called b4 sending alerts to staff.
$dept = $this->getDept();
......@@ -1330,10 +1341,12 @@ class Ticket {
$recipients[]=$manager;
$sentlist=array(); //I know it sucks...but..it works.
$options = array('references'=>$message->getEmailMessageId());
foreach( $recipients as $k=>$staff) {
if(!$staff || !$staff->getEmail() || !$staff->isAvailable() || in_array($staff->getEmail(), $sentlist)) continue;
$alert = str_replace('%{recipient}', $staff->getFirstName(), $msg['body']);
$email->sendAlert($staff->getEmail(), $msg['subj'], $alert);
$email->sendAlert($staff->getEmail(), $msg['subj'], $alert,
null, $options);
$sentlist[] = $staff->getEmail();
}
}
......@@ -1386,7 +1399,9 @@ class Ticket {
$msg['body'] ="\n$tag\n\n".$msg['body'];
$attachments =($cfg->emailAttachments() && $files)?$response->getAttachments():array();
$email->sendAutoReply($this->getEmail(), $msg['subj'], $msg['body'], $attachments);
$options = array('references' => $response->getEmailMessageId());
$email->sendAutoReply($this->getEmail(), $msg['subj'], $msg['body'], $attachments,
$options);
}
return $response;
......@@ -1441,8 +1456,10 @@ class Ticket {
//Set attachments if emailing.
$attachments = $cfg->emailAttachments()?$response->getAttachments():array();
$options = array('references' => $response->getEmailMessageId());
//TODO: setup 5 param (options... e.g mid trackable on replies)
$email->send($this->getEmail(), $msg['subj'], $msg['body'], $attachments);
$email->send($this->getEmail(), $msg['subj'], $msg['body'], $attachments,
$options);
}
return $response;
......@@ -1551,6 +1568,7 @@ class Ticket {
$recipients[]=$dept->getManager();
$attachments = $note->getAttachments();
$options = array('references' => $note->getEmailMessageId());
$sentlist=array();
foreach( $recipients as $k=>$staff) {
if(!is_object($staff)
......@@ -1559,7 +1577,8 @@ class Ticket {
|| $note->getStaffId() == $staff->getId()) //No need to alert the poster!
continue;
$alert = str_replace('%{recipient}', $staff->getFirstName(), $msg['body']);
$email->sendAlert($staff->getEmail(), $msg['subj'], $alert, $attachments);
$email->sendAlert($staff->getEmail(), $msg['subj'], $alert, $attachments,
$options);
$sentlist[] = $staff->getEmail();
}
}
......@@ -2128,7 +2147,12 @@ class Ticket {
$msg['body'] ="\n$tag\n\n".$msg['body'];
$attachments =($cfg->emailAttachments() && $response)?$response->getAttachments():array();
$email->send($ticket->getEmail(), $msg['subj'], $msg['body'], $attachments);
$references = $ticket->getLastMessage()->getEmailMessageId();
if (isset($response))
$references = array($response->getEmailMessageId(), $references);
$options = array('references' => $references);
$email->send($ticket->getEmail(), $msg['subj'], $msg['body'], $attachments,
$options);
}
return $ticket;
......
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN"
"http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
<html version="-//W3C//DTD XHTML 1.1//EN" xmlns="http://www.w3.org/1999/xhtml" xml:lang="en">
<head>
<link rel="stylesheet" type="text/css" href="/s/d16ebb.css" title="Default"/>
<title>xkcd: Monster</title>
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
<link rel="shortcut icon" href="/s/919f27.ico" type="image/x-icon"/>
<link rel="icon" href="/s/919f27.ico" type="image/x-icon"/>
<link rel="alternate" type="application/atom+xml" title="Atom 1.0" href="/atom.xml"/>
<link rel="alternate" type="application/rss+xml" title="RSS 2.0" href="/rss.xml"/>
<link rel="apple-touch-icon-precomposed" href="/s/d9522a.png" />
<script type="text/javascript">
var _gaq = _gaq || [];
_gaq.push(['_setAccount', 'UA-25700708-7']);
_gaq.push(['_setDomainName', 'xkcd.com']);
_gaq.push(['_setAllowLinker', true]);
_gaq.push(['_trackPageview']);
(function() {
var ga = document.createElement('script'); ga.type = 'text/javascript'; ga.async = true;
ga.src = ('https:' == document.location.protocol ? 'https://ssl' : 'http://www') + '.google-analytics.com/ga.js';
var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(ga, s);
})();
</script>
</head>
<body>
<div id="topContainer">
<div id="topLeft">
<ul>
<li><a href="/archive">Archive</a></li>
<li><a href="http://what-if.xkcd.com">What If?</a></li>
<li><a href="http://blag.xkcd.com">Blag</a></li>
<li><a href="http://store.xkcd.com/">Store</a></li>
<li><a rel="author" href="/about">About</a></li>
</ul>
</div>
<div id="topRight">
<div id="masthead">
<span><a href="/"><img src="http://imgs.xkcd.com/static/terrible_small_logo.png" alt="xkcd.com logo" height="83" width="185"/></a></span>
<span id="slogan">A webcomic of romance,<br/> sarcasm, math, and language.</span>
</div>
<div id="news">
You can get the Subways comic as a <a href="http://store-xkcd-com.myshopify.com/products/subways">poster</a>!
</div>
</div>
<div id="bgLeft" class="bg box"></div>
<div id="bgRight" class="bg box"></div>
</div>
<div id="middleContainer" class="box">
<div id="ctitle">Monster</div>
<ul class="comicNav">
<li><a href="/1/">|&lt;</a></li>
<li><a rel="prev" href="/1256/" accesskey="p">&lt; Prev</a></li>
<li><a href="http://dynamic.xkcd.com/random/comic/">Random</a></li>
<li><a rel="next" href="#" accesskey="n">Next &gt;</a></li>
<li><a href="/">&gt;|</a></li>
</ul>
<div id="comic">
<img src="http://imgs.xkcd.com/comics/monster.png" title="It was finally destroyed with a nuclear weapon carrying the destructive energy of the Hiroshima bomb." alt="Monster" />
</div>
<ul class="comicNav">
<li><a href="/1/">|&lt;</a></li>
<li><a rel="prev" href="/1256/" accesskey="p">&lt; Prev</a></li>
<li><a href="http://dynamic.xkcd.com/random/comic/">Random</a></li>
<li><a rel="next" href="#" accesskey="n">Next &gt;</a></li>
<li><a href="/">&gt;|</a></li>
</ul>
<br />
Permanent link to this comic: http://xkcd.com/1257/<br />
Image URL (for hotlinking/embedding): http://imgs.xkcd.com/comics/monster.png
<div id="transcript" style="display: none"></div>
</div>
<div id="bottom" class="box">
<img src="http://imgs.xkcd.com/s/a899e84.jpg" width="520" height="100" alt="Selected Comics" usemap="#comicmap"/>
<map id="comicmap" name="comicmap">
<!-- http://code.google.com/p/chromium/issues/detail?id=108489 Might be MIME dependent. -->
<area shape="rect" coords="0,0,100,100" href="/150/" alt="Grownups"/>
<area shape="rect" coords="104,0,204,100" href="/730/" alt="Circuit Diagram"/>
<area shape="rect" coords="208,0,308,100" href="/162/" alt="Angular Momentum"/>
<area shape="rect" coords="312,0,412,100" href="/688/" alt="Self-Description"/>
<area shape="rect" coords="416,0,520,100" href="/556/" alt="Alternative Energy Revolution"/>
</map>
<div>
Search comic titles and transcripts:
<script type="text/javascript" src="//www.google.com/jsapi"></script>
<script type="text/javascript">google.load('search', '1');google.setOnLoadCallback(function() {google.search.CustomSearchControl.attachAutoCompletion('012652707207066138651:zudjtuwe28q',document.getElementById('q'),'cse-search-box');});</script>
<form action="//www.google.com/cse" id="cse-search-box">
<div>
<input type="hidden" name="cx" value="012652707207066138651:zudjtuwe28q"/>
<input type="hidden" name="ie" value="UTF-8"/>
<input type="text" name="q" id="q" size="31"/>
<input type="submit" name="sa" value="Search"/>
</div>
</form>
<script type="text/javascript" src="//www.google.com/cse/brand?form=cse-search-box&amp;lang=en"></script>
<a href="/rss.xml">RSS Feed</a> - <a href="/atom.xml">Atom Feed</a>
</div>
<br />
<div id="comicLinks">
Comics I enjoy:<br/>
<a href="http://threewordphrase.com/">Three Word Phrase</a>,
<a href="http://oglaf.com/">Oglaf</a> (nsfw),
<a href="http://www.smbc-comics.com/">SMBC</a>,
<a href="http://www.qwantz.com">Dinosaur Comics</a>,
<a href="http://www.asofterworld.com">A Softer World</a>,
<a href="http://buttersafe.com/">Buttersafe</a>,
<a href="http://pbfcomics.com/">Perry Bible Fellowship</a>,
<a href="http://questionablecontent.net/">Questionable Content</a>,
<a href="http://www.buttercupfestival.com/">Buttercup Festival</a>
</div>
<p>Warning: this comic occasionally contains strong language (which may be unsuitable for children), unusual humor (which may be unsuitable for adults), and advanced mathematics (which may be unsuitable for liberal-arts majors).</p>
<div id="footnote">BTC 1NfBXWqseXc9rCBc3Cbbu6HjxYssFUgkH6<br />We did not invent the algorithm. The algorithm consistently finds Jesus. The algorithm killed Jeeves. <br/>The algorithm is banned in China. The algorithm is from Jersey. The algorithm constantly finds Jesus.<br/>This is not the algorithm. This is close.</div>
<div id="licenseText">
<p>
This work is licensed under a
<a href="http://creativecommons.org/licenses/by-nc/2.5/">Creative Commons Attribution-NonCommercial 2.5 License</a>.
</p><p>
This means you're free to copy and share these comics (but not to sell them). <a rel="license" href="/license.html">More details</a>.</p>
</div>
</div>
</body>
<!-- Layout by Ian Clasbey, davean, and chromakode -->
</html>
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment