diff --git a/includes/mail.inc b/includes/mail.inc
index 3279967..797f14d 100644
--- a/includes/mail.inc
+++ b/includes/mail.inc
@@ -33,12 +33,10 @@ function _project_issue_mail_url_callback($match = FALSE) {
  * Add a section to the email body.
  * Note: URL link numbers are not reset between calls to this function.
  */
-function project_issue_mail_output(&$body, $html = 1, $format = FILTER_FORMAT_DEFAULT) {
+function project_issue_mail_output(&$body, $html = 1) {
   static $i = 0;
 
   if ($html) {
-    $body = check_markup($body, $format, FALSE);
-
     // Convert inline links into footer links.
     //$pattern = '@<a +([^ >]+ )*?href *= *"([^>"]+?)"[^>]*>([^<]+?)</a>@i';
     $pattern = '@<a[^>]*\shref\s*=\s*([\'"])([^>]+?)\1[^>]*>(.+?)</a>@is';
@@ -79,7 +77,6 @@ function project_issue_mail_notify($nid) {
   }
 
   $node = node_load($nid, NULL, TRUE);
-
   // We don't want any notifications for unpublished nodes.
   if (empty($node) || !$node->status) {
     return;
@@ -87,40 +84,27 @@ function project_issue_mail_notify($nid) {
 
   $project = node_load($node->field_project[LANGUAGE_NONE][0]['target_id']);
 
-  // Store a copy of the issue, so we can load the original issue values
-  // below.
-  $issue = drupal_clone($node);
+
+  $first_vid = db_query_range('SELECT vid FROM {node_revision} WHERE nid = :nid ORDER BY timestamp ASC', 0, 1, array(':nid' => $node->nid))->fetchField();
 
   // Load in the original issue data here, since we want a running
   // reverse history.
-  // TODO: Where is original_issue_data stored now?
-  $original_issue_data = unserialize($node->project_issue['original_issue_data']);
-  // TODO: This function doesn't seem to exist in 7.x.
-  $fields = project_issue_field_labels('email');
-  foreach ($fields as $field => $label) {
-    if ($field != 'name' && $field != 'updator') {
-      $issue->original_issue_metadata->$field = $original_issue_data->$field;
-    }
-  }
+  $original = node_load($node->nid, $first_vid);
 
   // Record users that are connected to this issue.
   $uids = array();
   if (!empty($node->uid)) {
     $uids[$node->uid] = $node->uid;
   }
-  $assigned_uid = $node->field_issue_assigned[LANGUAGE_NONE][0]['target_id'];
-  if (!empty($assigned_uid)) {
+
+  if (!empty($node->field_issue_assigned)) {
+    $assigned_uid = $node->field_issue_assigned[LANGUAGE_NONE][0]['target_id'];
     $uids[$assigned_uid] = $assigned_uid;
   }
 
-  // Create complete history of the bug report.
-  $history = array($issue);
-  $result = db_query('SELECT u.name, c.cid, c.nid, c.subject, c.comment, c.uid, c.format, pic.* FROM {project_issue_comments} pic INNER JOIN {comments} c ON c.cid = pic.cid INNER JOIN {users} u ON u.uid = c.uid WHERE c.nid = %d AND c.status = %d ORDER BY pic.timestamp', $node->nid, COMMENT_PUBLISHED);
-
-  while ($comment = db_fetch_object($result)) {
-    $comment->comment = db_decode_blob($comment->comment);
-    $comment->files = comment_upload_load_files($comment->cid);
-    $history[] = $comment;
+  $thread = comment_get_thread($node, COMMENT_MODE_FLAT, 1000);
+  $history = comment_load_multiple($thread);
+  foreach ($history as $cid => $comment) {
     // Record users that are connected to this issue.
     if ($comment->uid) {
       $uids[$comment->uid] = $comment->uid;
@@ -128,7 +112,7 @@ function project_issue_mail_notify($nid) {
   }
 
   // Check whether Flag module integration is enabled for e-mail notifications.
-  $follow_flag = project_issue_get_follow_flag($node->type);
+  $follow_flag = project_issue_get_follow_flag($node);
 
   // If flag integration is present, the list of users for 'own' issues will
   // be those that flagged the issue.
@@ -155,44 +139,47 @@ function project_issue_mail_notify($nid) {
   // and configured for following issues.
   $recipients = array();
 
-  // In both queries, the first placeholder is always the node ID of the
-  // project that the current issue belongs to, since we need to restrict to
-  // that project for the per-project settings, and LEFT JOIN on that project
-  // to catch all the users with a default notification setting (everyone who
-  // doesn't have a specific preference for that project).
-  $args = array($node->project_issue['pid']);
-
-  // Build up filters for different issue notification levels.
-  $filter_array = array();
+  $filter_or = db_or();
   // 'All' level.
-  $filter_array[] = "(pi.level = " . PROJECT_ISSUE_NOTIFICATION_ALL . ")";
+  $filter_or->condition('pi.level', PROJECT_ISSUE_NOTIFICATION_ALL);
   // 'Own' level using the list of users attached to the issue.
   if (!empty($own_issues_uids)) {
-    $placeholders = implode(',', array_fill(0, count($own_issues_uids), '%d'));
-    $filter_array[] = "(pi.level = " . PROJECT_ISSUE_NOTIFICATION_OWN . " AND u.uid IN ($placeholders))";
-    $args = array_merge($args, $own_issues_uids);
-  }
-  $filter = implode(" OR ", $filter_array);
-
-  // Pull users that want notifications at the project level.
-  $project_notification_query = "SELECT pi.uid, u.name, u.mail
-    FROM {project_issue_notification_project} pi
-    INNER JOIN {users} u ON pi.uid = u.uid
-    WHERE u.status = 1 AND pi.nid = %d AND (" . $filter . ")";
-  $result = db_query($project_notification_query, $args);
-  while ($account = db_fetch_object($result)) {
+    $filter_or->condition(
+      db_and()
+        ->condition('pi.level', PROJECT_ISSUE_NOTIFICATION_OWN)
+        ->condition('u.uid', $own_issues_uids, 'IN')
+    );
+  }
+
+  $query = db_select('project_issue_notification_project', 'pi');
+  $query->innerJoin('users', 'u', 'pi.uid = u.uid');
+  $query
+    ->condition('u.status', 1)
+    ->condition('pi.nid', $project->nid)
+    ->condition($filter_or);
+  $query->addField('pi', 'uid');
+  $query->addField('u', 'name');
+  $query->addField('u', 'mail');
+  $result = $query->execute();
+
+  foreach ($result as $account) {
     $recipients[$account->uid] = $account;
   }
 
   // Pull users that want global notifications if they haven't defined a
   // per-project setting for this project.
-  $default_notification_query = "SELECT pi.uid, u.name, u.mail
-    FROM {project_issue_notification_global} pi
-    LEFT JOIN {project_issue_notification_project} pinp ON pi.uid = pinp.uid AND pinp.nid = %d
-    INNER JOIN {users} u ON pi.uid = u.uid
-    WHERE u.status = 1 AND pinp.level IS NULL AND (" . $filter . ")";
-  $result = db_query($default_notification_query, $args);
-  while ($account = db_fetch_object($result)) {
+  $query = db_select('project_issue_notification_global', 'pi');
+  $query->leftJoin('project_issue_notification_project', 'pinp', 'pi.uid = pinp.uid AND pinp.nid = :pinp_nid', array(':pinp_nid' => $project->nid));
+  $query->innerJoin('users', 'u', 'pi.uid = u.uid');
+  $query
+    ->condition('u.status', 1)
+    ->isNull('pinp.level')
+    ->condition($filter_or);
+  $query->addField('pi', 'uid');
+  $query->addField('u', 'name');
+  $query->addField('u', 'mail');
+  $result = $query->execute();
+  foreach ($result as $account) {
     $recipients[$account->uid] = $account;
   }
 
@@ -200,7 +187,8 @@ function project_issue_mail_notify($nid) {
   // authenticated role has the 'view uploaded files' permission, since
   // we only need to process each user's file access permission if this
   // is NOT the case.
-  $check_file_perms = !db_result(db_query("SELECT COUNT(*) FROM {permission} WHERE perm LIKE '%view uploaded files%' AND rid IN (%d, %d)", DRUPAL_ANONYMOUS_RID, DRUPAL_AUTHENTICATED_RID));
+
+  // TODO: There is no such thing as "view uploaded files" in Drupal 7 so this needs to be refactored for field access.
 
   // We need to determine if node_access() checks are necessary.  The
   // check will be needed if any of the following is true:
@@ -215,14 +203,16 @@ function project_issue_mail_notify($nid) {
   $grants = module_implements('node_grants');
   $check_node_access = $node->status != 1 || empty($anon_auth_access) || !empty($grants);
 
-  $params['node']    = $node;
-  $params['project'] = $project;
-  $params['history'] = $history;
+  $params['node']     = $node;
+  $params['followup'] = empty($history) ? FALSE : end($history);
+  $params['project']  = $project;
+  $params['history']  = $history;
 
+  $sender = new stdClass;
   $sender->name = t('!name (!site)', array('!name' => $user->name, '!site' => variable_get('site_name', 'Drupal')));
   // @todo: the %project token replacement here is broken and should be ported
   // to use machine_name.
-  $sender->mail = strtr(variable_get('project_issue_reply_to_' . $node->type, variable_get('site_mail', ini_get('sendmail_from'))), array('%project' => $project->project['uri']));
+  $sender->mail = strtr(variable_get('project_issue_reply_to_' . $node->type, variable_get('site_mail', ini_get('sendmail_from'))), array('%project' => $project->field_project_machine_name[LANGUAGE_NONE][0]['value']));
   // The sender name is enclosed by double quotes below
   // to satisfy RFC2822 <http://www.faqs.org/rfcs/rfc2822.html>,
   // which requires double quotes when special characters (including
@@ -254,11 +244,11 @@ function project_issue_mail_notify($nid) {
     }
   }
 
-  if (is_array($project->project_issue['mail_copy_filter']) && count(array_filter($project->project_issue['mail_copy_filter'])) && !$project->project_issue['mail_copy_filter'][$node->project_issue['category']]) {
+  if (isset($project->project_issue['mail_copy_filter']) && is_array($project->project_issue['mail_copy_filter']) && count(array_filter($project->project_issue['mail_copy_filter'])) && !$project->project_issue['mail_copy_filter'][$node->project_issue['category']]) {
     return;
   }
 
-  if (is_array($project->project_issue['mail_copy_filter_state']) && count(array_filter($project->project_issue['mail_copy_filter_state'])) && !$project->project_issue['mail_copy_filter_state'][$node->project_issue['sid']]) {
+  if (isset($project->project_issue['mail_copy_filter_state']) && is_array($project->project_issue['mail_copy_filter_state']) && count(array_filter($project->project_issue['mail_copy_filter_state'])) && !$project->project_issue['mail_copy_filter_state'][$node->project_issue['sid']]) {
     return;
   }
 
@@ -282,17 +272,16 @@ function _project_issue_mail($key, &$message, $params) {
       $node    = $params['node'];
       $project = $params['project'];
       $history = $params['history'];
-      $fields  = project_issue_field_labels('email');
 
       $domain = preg_replace('|.+://([a-zA-Z0-9\._-]+).*|', '\1', $base_url);
 
       $message['headers'] += array(
         'Date' => date('r'),
         'X-Mailer' => 'Drupal Project module (http://drupal.org/project/project)',
-        'List-Id' => "$project->title <" . $project->project['uri'] . "-issues-$domain>",
-        'List-Archive' => '<' . url('project/issues/' . $project->project['uri'], array('absolute' => TRUE)) . '>',
-        'List-Subscribe' => '<' . url('project/issues/subscribe-mail/' . $project->project['uri'], array('absolute' => TRUE)) . '>',
-        'List-Unsubscribe' => '<' . url('project/issues/subscribe-mail/' . $project->project['uri'], array('absolute' => TRUE)) . '>',
+        'List-Id' => "$project->title <" . $project->field_project_machine_name[LANGUAGE_NONE][0]['value'] . "-issues-$domain>",
+        'List-Archive' => '<' . url('project/issues/' . $project->field_project_machine_name[LANGUAGE_NONE][0]['value'], array('absolute' => TRUE)) . '>',
+        'List-Subscribe' => '<' . url('project/issues/subscribe-mail/' . $project->field_project_machine_name[LANGUAGE_NONE][0]['value'], array('absolute' => TRUE)) . '>',
+        'List-Unsubscribe' => '<' . url('project/issues/subscribe-mail/' . $project->field_project_machine_name[LANGUAGE_NONE][0]['value'], array('absolute' => TRUE)) . '>',
       );
 
       // Comments exist, set headers accordingly.
@@ -313,19 +302,19 @@ function _project_issue_mail($key, &$message, $params) {
         $message['headers']['Message-Id'] = "<type=project&nid=$node->nid&host=@$domain>";
       }
 
-      project_issue_mail_output($node->title, 0);
+      $content['title'] = array('#markup' => '<h2>' . check_plain($node->title) . '</h2>');
 
       // Create link to related node
       $links = t('Issue status update for !link', array('!link' => "\n" . url("node/$node->nid", array('absolute' => TRUE)))) . "\n";
       $links .= t('Post a follow up: !link', array('!link' => "\n" . url("comment/reply/$node->nid", array('fragment' => 'comment-form', 'absolute' => TRUE)))) . "\n";
-      $body = project_issue_mail_generate_followup_mail_body($node, $history, $params['display_files'], $params['recipient']);
+      $body = project_issue_mail_generate_followup_mail_body($params);
 
       $preferences = $params['recipient']->project_issue_notification;
 
       // Construct the appropriate subject based on the user's preferences.
       $subject_tokens = array(
-        '!short_name' => $project->project['uri'],
-        '!category' => $node->project_issue['category'],
+        '!short_name' => $project->field_project_machine_name[LANGUAGE_NONE][0]['value'],
+        '!category' => $node->field_issue_category[LANGUAGE_NONE][0]['value'],
         '!title' => $node->title,
       );
       $subject = array();
@@ -340,8 +329,10 @@ function _project_issue_mail($key, &$message, $params) {
       $subject = strtr(implode(' ', $subject), $subject_tokens);
 
       $message['subject'] = $subject;
-      $message['body']['links'] = $links;
-      $message['body']['body'] = $body;
+      $content['links'] = array('#markup' => $links);
+      $content['body'] = array('#markup' => $body);
+
+      $message['body'][] = drupal_render($content);
       break;
 
     case 'project_issue_critical_summary':
@@ -387,10 +378,14 @@ function _project_issue_mail($key, &$message, $params) {
  * @return
  *   A string of the email body.
  */
-function project_issue_mail_generate_followup_mail_body($node, $history, $display_files, $recipient = NULL) {
+function project_issue_mail_generate_followup_mail_body(&$params) {
   global $user;
   static $cache = array();
 
+  $node = $params['node'];
+  $history = $params['history'];
+  $display_files = $params['display_files'];
+  $recipient = $params['recipient'];
   $mail_body = $recipient->project_issue_notification['mail_body'];
 
   // Return cached output if available.
@@ -398,52 +393,38 @@ function project_issue_mail_generate_followup_mail_body($node, $history, $displa
     return $cache[$display_files][$mail_body];
   }
 
-  // Get most recent update.
-  $entry = array_pop($history);
-
-  $node->project_issue['updator'] = $entry->name ? $entry->name : $user->name;
-
-  // Check if the latest entry is actually the initial issue.
-  if (empty($history)) {
-    $metadata_previous = new stdClass();
-    // Have to get the metadata into the entry object.
-    $metadata_entry = $entry->original_issue_metadata;
-    $content = $entry->body;
+  if (isset($params['comment'])) {
+    $entry = $params['comment'];
+    $content = drupal_render(field_view_field('comment', $entry, 'comment_body'));
   }
   else {
-    $metadata_previous = end($history);
-    // If the previous was the original issue, then we need to pull
-    // out the metadata from project_issue.
-    if (isset($metadata_previous->original_issue_metadata)) {
-      $metadata_previous = $metadata_previous->original_issue_metadata;
-    }
-    $metadata_entry = $entry;
-    $content = $entry->comment;
+    $entry = $params['node'];
+    $content = drupal_render(field_view_field('node', $entry, 'body'));
   }
+  $node->project_issue['updator'] = !empty($entry->name) ? $entry->name : $user->name;
 
-  $fields = project_issue_field_labels('email');
-  $comment_changes = project_issue_metadata_changes($node, $metadata_previous, $metadata_entry, $fields);
+  $changes = project_issue_metadata_changes($node);
 
   // Since $node->name will always be the original issue author, and since
   // $node->project_issue['updator'] isn't a property of either $previous or
   // $entry, these two properties will never show up as being different when
   // project_issue_metadata_changes() is called, and therefore neither of
-  // these will ever be elements of the $comment_changes array.  Since we do
+  // these will ever be elements of the $changes array.  Since we do
   // want them to be printed in issue emails, we just need to add their labels
-  // back into the $comment_changes array here, so that
+  // back into the $changes array here, so that
   // theme_project_issue_mail_summary_field() will know to print the data for
   // these two fields.
-  $comment_changes['name'] = array(
-    'label' => $fields['name'],
+  $changes['name'] = array(
+    'label' => t('Reported by'),
   );
-  $comment_changes['updator'] = array(
-    'label' => $fields['updator'],
+  $changes['updator'] = array(
+    'label' => t('Updated by'),
   );
 
-  $summary = theme('project_issue_mail_summary', $entry, $node, $comment_changes, $display_files);
+  $summary = theme('project_issue_mail_summary', array('entry' => $entry, 'node' => $node, 'changes' => $changes, 'display_files' => $display_files));
 
   // Create main body content
-  project_issue_mail_output($content, 1, $entry->format);
+  project_issue_mail_output($content, 1);
   $body = "$content\n$entry->name\n";
 
   // Append complete follow-up history if recient prefers that.
@@ -489,14 +470,16 @@ function project_issue_mail_generate_followup_mail_body($node, $history, $displa
  * @return
  *   A string containing the themed text of the issue metadata table.
  */
-function theme_project_issue_mail_summary($entry, $node, $changes, $display_files) {
+function theme_project_issue_mail_summary($variables) {
   // Mail summary (status values).
   $summary = '';
-  foreach ($changes as $field => $change) {
-    $summary .= theme('project_issue_mail_summary_field', $node, $field, $change);
+  foreach ($variables['changes'] as $field => $change) {
+    $summary .= theme('project_issue_mail_summary_field', array('node' => $variables['node'], 'field_name' => $field, 'change' => $change)) . "\n";
+  }
+  if ($variables['display_files']) {
+    $summary .= drupal_render(field_view_field('node', $variables['node'], 'field_issue_files'));
   }
 
-  $summary .= project_issue_mail_format_attachments($entry, $display_files);
   return $summary;
 }
 
@@ -513,38 +496,21 @@ function theme_project_issue_mail_summary($entry, $node, $changes, $display_file
  * @return
  *  A themed line or lines of text ready for inclusion into the email body.
  */
-function theme_project_issue_mail_summary_field($node, $field_name, $change) {
-  // We need to run the label name through strip_tags here so that
-  // the spacing isn't messed up if there are HTML tags in $change['label'].
-  $text = str_pad(strip_tags($change['label']) . ':', 14);
-  $summary_row = '';
-  if (!empty($change['label']) && isset($change['old']) && isset($change['new']) && $field_name != 'updator' && $field_name != 'name') {
-    if (is_array($change['old']) || is_array($change['new'])) {
-      $removed = array();
-      if (is_array($change['old'])) {
-        foreach ($change['old'] as $item) {
-          $removed[] = '-' . $item;
-        }
-      }
-      elseif (!empty($change['old'])) {
-        $removed[] = '-' . $change['old'];
-      }
-
-      $added = array();
-      if (is_array($change['new'])) {
-        foreach ($change['new'] as $item) {
-          $added[] = '+' . $item;
-        }
-      }
-      elseif (!empty($change['new'])) {
-        $added[] = '+' . $change['new'];
-      }
+function theme_project_issue_mail_summary_field($variables) {
+  $change = $variables['change'];
+  $field_name = $variables['field_name'];
+  $node = $variables['node'];
+  $summary_row = array();
 
-      $summary_row = " $text" . trim(implode(', ', $removed) . '  ' . implode(', ', $added)) . "\n";
+  if (!empty($change['label']) && isset($change['old']) && isset($change['new']) && $field_name != 'updator' && $field_name != 'name') {
+    if (is_array($change['old']) && is_array($change['new'])) {
+      $summary_row[] = array('#markup' => $change['label']) . ': ';
+      $summary_row[] = array('#prefix' => '-') + field_view_value('node', $node, $field_name, $change['old']);
+      $summary_row[] = array('#prefix' => ' +') + field_view_value('node', $node, $field_name, $change['new']);
     }
     else {
-      $summary_row .= "-$text" . project_issue_change_summary($field_name, $change['old']) . "\n";
-      $summary_row .= "+$text" . project_issue_change_summary($field_name, $change['new']) . "\n";
+      $summary_row[] = array('#markup' => '-' . $change['old']);
+      $summary_row[] = array('#markup' => ' +'  . $change['new']);
     }
   }
   elseif (!empty($change['label'])) {
@@ -552,10 +518,10 @@ function theme_project_issue_mail_summary_field($node, $field_name, $change) {
       // This condition is necessary when building the first email message of an
       // issue, since in this case $change['old'] should not exist.
       if (is_array($change['new'])) {
-        $summary_row .= " $text" . implode(', ', $change['new']) . "\n";
+        $summary_row[] = field_view_value('node', $node, $field_name, $change['new']);
       }
       else {
-        $summary_row .= " $text" . project_issue_change_summary($field_name, $change['new']) . "\n";
+        $summary_row[] = array('#markup' => $change['new']);
       }
     }
     else {
@@ -563,38 +529,12 @@ function theme_project_issue_mail_summary_field($node, $field_name, $change) {
       // which haven't changed but should be printed anyway get processed.
       // For example, the project, category, etc. are printed in each email
       // whether or not they have changed.
-      // @TODO: Should we really assume the field in is $node->project_issue[]?
-      if (isset($node->project_issue[$field_name])) {
-        $summary_row .= " $text" . project_issue_change_summary($field_name, $node->project_issue[$field_name]) . "\n";
-      }
+      $summary_row[] = field_view_field('node', $node, $field_name);
     }
   }
   // HTML tags in the email will make it hard to read, so pass
   // this output through strip_tags().
-  return strip_tags($summary_row);
-}
-
-/**
- * Formats attachments for issue notification e-mails.
- *
- * @param $entry
- *   An issue or followup object containing the file data.
- * @param $display_files
- *   Boolean indicating if file attachments should be displayed.
- * @return
- *   A formatted string of file attachments.
- */
-function project_issue_mail_format_attachments($entry, $display_files) {
-  $output = '';
-  if ($display_files && is_array($entry->files)) {
-    foreach ($entry->files as $file) {
-      // Comment upload has it's files in an array, so cast to an object
-      // for consistency.
-      $file = (object) $file;
-      $output .= ' ' . str_pad(t('Attachment') . ':', 14) . file_create_url($file->filepath) . ' (' . format_size($file->filesize) . ")\n";
-    }
-  }
-  return $output;
+  return drupal_render($summary_row);
 }
 
 /**
@@ -628,18 +568,17 @@ function project_issue_mail_format_entry($entry, $display_files, $is_original =
     $output .= url("node/$entry->nid", array('fragment' => "comment-$entry->cid", 'absolute' => TRUE)) . "\n";
   }
 
-  $output .= project_issue_mail_format_attachments($entry, $display_files);
-
   // Must distinguish between nodes and comments -- here we do it
   // by looking for a revision ID.
   if (empty($entry->vid)) {
-    $content = $entry->comment;
+    $content = drupal_render(field_view_field('comment', $entry, 'comment_body'));
   }
   else {
-    $content = $entry->body;
+    $content = drupal_render(field_view_field('node', $entry, 'body'));
+    $output .= drupal_render(field_view_field('node', $entry, 'field_issue_files'));
   }
 
-  project_issue_mail_output($content, 1, $entry->format);
+  project_issue_mail_output($content);
 
   if ($content) {
     $output .= "\n$content";
diff --git a/project_issue.module b/project_issue.module
index 95898ed..221f927 100644
--- a/project_issue.module
+++ b/project_issue.module
@@ -230,6 +230,23 @@ function project_issue_theme() {
         'links' => array(),
       ),
     ),
+    'project_issue_mail_summary' => array(
+      'variables' => array(
+        'entry' => NULL,
+        'node' => NULL,
+        'changes' => array(),
+        'display_files' => FALSE,
+      ),
+      'file' => 'includes/mail.inc',
+    ),
+    'project_issue_mail_summary_field' => array(
+      'variables' => array(
+        'node' => NULL,
+        'field_name' => NULL,
+        'change' => NULL,
+      ),
+      'file' => 'includes/mail.inc',
+    ),
   );
 }
 
@@ -929,6 +946,51 @@ function project_issue_link_filter_callback($matches) {
 }
 
 /**
+ * Store issue nodes that need mail notifications sent.
+ *
+ * It's possible that mass inserts/updates could occur, and also possible that
+ * a given node/comment could be programatically updated more than once in a
+ * page load -- an associative array is used in order to support these cases.
+ *
+ * @param $nid
+ *   The node ID of the issue node to store, or NULL to fetch the stored nids.
+ * @return
+ *   If $nid is not passed, an associative array of nids that are marked for
+ *   notification emails, with the following structure: key = nid, value = nid.
+ */
+function project_issue_set_mail_notify($nid = NULL) {
+  static $nids = array();
+
+  if (!isset($nid)) {
+    $return = $nids;
+    $nids = array();  // Reset just in case this function gets called again.
+    return $return;
+  }
+  else {
+    $nids[$nid] = $nid;
+  }
+}
+
+/**
+ * Implements hook_exit().
+ */
+function project_issue_exit() {
+  // Check for issue nodes that need mail notifications sent. This is done in
+  // hook_exit() so that all issue and file data is in a consistent state
+  // before we generate the email.
+  $nids = project_issue_set_mail_notify();
+  // For cached pages, this hook is called, but there aren't any mail functions
+  // loaded. Since the cached pages won't have any new mail notifications,
+  // we can safely test for this case.
+  if (!empty($nids)) {
+    module_load_include('inc', 'project_issue', 'includes/mail');
+    foreach ($nids as $nid) {
+      project_issue_mail_notify($nid);
+    }
+  }
+}
+
+/**
  * Implementation of hook_requirements().
  *
  * Check for conflicts with:
@@ -2076,4 +2138,144 @@ function project_issue_node_insert($node) {
     // @todo: This should probably be a preference at user/N/project-issue
     project_issue_notification_project_setting_save($node->uid, $node->nid, PROJECT_ISSUE_NOTIFICATION_ALL);
   }
+  elseif (project_issue_node_type_is_issue($node->type)) {
+    // Mark the node for email notification during hook_exit().
+    project_issue_set_mail_notify($node->nid);
+  }
+}
+
+/**
+ * Implements hook_node_update().
+ */
+function project_issue_node_update($node) {
+  if (project_issue_node_type_is_issue($node->type)) {
+    // Generate the difference now that we have original node available.
+    project_issue_metadata_changes($node, $node->original);
+  }
+}
+
+/**
+ * Implements hook_comment_insert().
+ */
+function project_issue_comment_insert($comment) {
+  $node = node_load($comment->nid);
+  if ($node && project_issue_node_type_is_issue($node->type)) {
+    // Mark the node for email notification during hook_exit().
+    project_issue_set_mail_notify($node->nid);
+  }
+}
+
+/**
+ * Calculate the differences in project_issue comment metadata
+ * between the original issue and the new.
+ *
+ * @param $node
+ *   Object containing the new issue node.
+ * @param $old_node
+ *   Object containing the old issue node.
+ *
+ * @return
+ *  An associative array containing information about changes between
+ *  the two objects.
+ *  For example:
+ *  array(
+ *    'component' => array(
+ *      'label' => t('Component'),
+ *      'old' => 'Code',
+ *      'new' => 'User interface',
+ *    ),
+ *    'sid' => array(
+ *      'label' => t('Status'),
+ *      'old' => 8,
+ *      'new' => 13,
+ *    ),
+ *  )
+ */
+function project_issue_metadata_changes($node, $old_node = NULL) {
+  $metadata_changes = &drupal_static(__FUNCTION__);
+  if (!isset($old_node)) {
+    // Just return old changes.
+    return isset($metadata_changes[$node->nid]) ? $metadata_changes[$node->nid] : array();
+  }
+  // Reset changes for this node.
+  $metadata_changes[$node->nid] = array();
+  // Keep it simple.
+  $changes = &$metadata_changes[$node->nid];
+  $instances = field_info_instances('node', $node->type);
+  foreach ($instances as $field_name => $field) {
+    if ($field_name == 'body') {
+      // do not handle for now or ever.
+      continue;
+    }
+    if (!empty($old_node->$field_name) || !empty($node->$field_name)) {
+      $changes[$field_name] = array('label' => $field['label']);
+    }
+    if (!empty($old_node->$field_name) && !empty($node->$field_name)) {
+      $old_items = field_get_items('node', $old_node, $field_name);
+      $new_items = field_get_items('node', $node, $field_name);
+      // We must get the key that holds the value in the field array.
+      // This will not work with all fields.
+      // @todo figure out a better way.
+      $key = key($new_items[0]);
+      // We wont loop, but just check first value.
+      // @todo maybe account for multiple items fields?
+      if ($old_items[0][$key] != $new_items[0][$key]) {
+        $changes[$field_name]['old'] = $old_items[0];
+        $changes[$field_name]['new'] = $new_items[0];
+      }
+    }
+    elseif (!empty($old_node->$field_name)) {
+      $old_items = field_get_items('node', $old_node, $field_name);
+      $changes[$field_name]['old'] = $old_items[0];
+    }
+    elseif (!empty($node->$field_name)) {
+      $new_items = field_get_items('node', $node, $field_name);
+      $changes[$field_name]['new'] = $new_items[0];
+    }
+  }
+
+  // Allow other modules to implement hook_project_issue_metadata() so that they
+  // can find changes in additional metadata.  In most cases other modules will
+  // be responsible for storing this metadata in their own tables.  Developers
+  // of modules that implement this hook should keep in mind the following:
+  // 1.  Implementations of hook_project_issue_metadata() must take the
+  //     $changes array by reference.
+  // 2.  Differences in properties will only be processed later on for
+  //     elements of the array which have the 'label', 'old', and 'new' properties
+  //     defined.
+  // In other words, for each line in the differences table (or field in the email)
+  // that is displayed, your hook should add something like the following as a
+  // new element of the $changes array:
+  //    'taxonomy_vid_10' => array(
+  //       'label' => 'Vocabulary 10',
+  //       'old' => 'MySQL, pgSQL, javascript',
+  //       'new' => 'pgSQL, newbie',
+  //     ),
+  //
+  // There are two methods you can use to indicate multiple changes of a field.
+  // The first is that for 'old' and 'new' you pass strings separated by some
+  // character, customarily a comma.  This method is used in
+  // the example above.  When using this method, the default display of the changes
+  // will be to show all old values followed by all new values.  In the example
+  // above, this would be displayed like:
+  // Vocabulary 10:  MySQL, pgSQL, javascript >> pgSQL, newbie
+  //
+  // The other method you can use when constructing 'old' and 'new' is to make
+  // both of these arrays, with each element of the array one change.  If you
+  // use this method, all elements in the 'old' array are typically interpreted
+  // as being removed, and all elements in the 'new' array are typically interpreted
+  // as being added.  An example of this type of structure is as follows:
+  //    'taxonomy_vid_10' => array(
+  //       'label' => 'Vocabulary 10',
+  //       'old' => array('MySQL', 'javascript'),
+  //       'new' => array('newbie'),
+  //     ),
+  // In this situation, the default display of these changes in a project issue
+  // metadata table would be as follows:
+  // Vocabulary 10:  -MySQL, -javascript    +newbie
+  foreach (module_implements('project_issue_metadata') as $module) {
+    $function = $module .'_project_issue_metadata';
+    $function('diff', $node, $changes, $old_node);
+  }
+  return $changes;
 }
