Fossil

Documentation
Login
/*
** Copyright (c) 2007 D. Richard Hipp
**
** This program is free software; you can redistribute it and/or
** modify it under the terms of the Simplified BSD License (also
** known as the "2-Clause License" or "FreeBSD License".)
**
** This program is distributed in the hope that it will be useful,
** but without any warranty; without even the implied warranty of
** merchantability or fitness for a particular purpose.
**
** Author contact information:
**   drh@hwaci.com
**   http://www.hwaci.com/drh/
**
*******************************************************************************
**
** Email notification features
*/
#include "config.h"
#include "email.h"
#include <assert.h>

/*
** SQL code to implement the tables needed by the email notification
** system.
*/
static const char zEmailInit[] =
@ -- Subscribers are distinct from users.  A person can have a log-in in
@ -- the USER table without being a subscriber.  Or a person can be a
@ -- subscriber without having a USER table entry.  Or they can have both.
@ -- In the last case the suname column points from the subscriber entry
@ -- to the USER entry.
@ --
@ -- The ssub field is a string where each character indicates a particular
@ -- type of event to subscribe to.  Choices:
@ --     a - Announcements
@ --     c - Check-ins
@ --     t - Ticket changes
@ --     w - Wiki changes
@ -- Probably different codes will be added in the future.  In the future
@ -- we might also add a separate table that allows subscribing to email
@ -- notifications for specific branches or tags or tickets.
@ --
@ CREATE TABLE repository.subscriber(
@   subscriberId INTEGER PRIMARY KEY, -- numeric subscriber ID.  Internal use
@   subscriberCode TEXT UNIQUE,       -- UUID for subscriber.  External use
@   sname TEXT,                       -- Human readable name
@   suname TEXT,                      -- Corresponding USER or NULL
@   semail TEXT,                      -- email address
@   sverify BOOLEAN,                  -- email address verified
@   sdonotcall BOOLEAN,               -- true for Do Not Call 
@   sdigest BOOLEAN,                  -- true for daily digests only
@   ssub TEXT,                        -- baseline subscriptions
@   sctime DATE,                      -- When this entry was created. JulianDay
@   smtime DATE,                      -- Last change.  JulianDay
@   sipaddr TEXT,                     -- IP address for last change
@   spswdHash TEXT                    -- SHA3 hash of password
@ );
@ 
@ -- Email notifications that need to be sent.
@ --
@ -- If the eventid key is an integer, then it corresponds to the
@ -- EVENT.OBJID table.  Other kinds of eventids are reserved for
@ -- future expansion.
@ --
@ CREATE TABLE repository.email_pending(
@   eventid ANY PRIMARY KEY,          -- Object that changed
@   sentSep BOOLEAN DEFAULT false,    -- individual emails sent
@   sentDigest BOOLEAN DEFAULT false  -- digest emails sent
@ ) WITHOUT ROWID;
@ 
@ -- Record bounced emails.  If too many bounces are received within
@ -- some defined time range, then cancel the subscription.  Older
@ -- entries are periodically purged.
@ --
@ CREATE TABLE repository.email_bounce(
@   subscriberId INTEGER, -- to whom the email was sent.
@   sendTime INTEGER,     -- seconds since 1970 when email was sent
@   rcvdTime INTEGER      -- seconds since 1970 when bounce was received
@ );
;

/*
** Make sure the unversioned table exists in the repository.
*/
void email_schema(void){
  if( !db_table_exists("repository", "subscriber") ){
    db_multi_exec(zEmailInit/*works-like:""*/);
  }
}


/*
** WEBPAGE: setup_email
**
** Administrative page for configuring and controlling email notification
*/
void setup_email(void){
  static const char *const azSendMethods[] = {
    "off",  "Disabled",
    "pipe", "Pipe to a command",
    "db",   "Store in a database",
    "file", "Store in a directory"
  };
  login_check_credentials();
  if( !g.perm.Setup ){
    login_needed(0);
    return;
  }
  db_begin_transaction();

  style_header("Email Notification Setup");
  @ <form action="%R/setup_email" method="post"><div>
  @ <input type="submit"  name="submit" value="Apply Changes" /><hr>
  login_insert_csrf_secret();
  multiple_choice_attribute("Email Send Method","email-send-method",
       "esm", "off", count(azSendMethods)/2, azSendMethods);
  @ <p>How to send email.  The "Pipe to a command"
  @ method is the usual choice in production.
  @ (Property: "email-send-method")</p>
  @ <hr>
  entry_attribute("Command To Pipe Email To", 80, "esc",
                   "email-send-command", "sendmail -t", 0);
  @ <p>When the send method is "pipe to a command", this is the command
  @ that is run.  Email messages are piped into the standard input of this
  @ command.  The command is expected to extract the sender address,
  @ recepient addresses, and subject from the header of the piped email
  @ text.  (Property: "email-send-command")</p>

  entry_attribute("Database In Which To Store Email", 60, "esdb",
                   "email-send-db", "", 0);
  @ <p>When the send method is "store in a databaes", each email message is
  @ stored in an SQLite database file with the name given here.
  @ (Property: "email-send-db")</p>

  entry_attribute("Directory In Which To Store Email", 60, "esdir",
                   "email-send-dir", "", 0);
  @ <p>When the send method is "store in a directory", each email message is
  @ stored as a separate file in the directory shown here.
  @ (Property: "email-send-dir")</p>
  @ <hr>

  entry_attribute("\"From\" email address", 40, "ef",
                   "email-self", "", 0);
  @ <p>This is the email from which email notifications are sent.  The
  @ system administrator should arrange for emails sent to this address
  @ to be handed off to the "fossil email incoming" command so that Fossil
  @ can handle bounces. (Property: "email-self")</p>
  @ <hr>

  entry_attribute("Administrator email address", 40, "ea",
                   "email-admin", "", 0);
  @ <p>This is the email for the human administrator for the system.
  @ Abuse and trouble reports are send here.
  @ (Property: "email-admin")</p>
  @ <hr>
  @ <p><input type="submit"  name="submit" value="Apply Changes" /></p>
  @ </div></form>
  db_end_transaction(0);
  style_footer();
}

/*
** Encode pMsg as MIME base64 and append it to pOut
*/
static void append_base64(Blob *pOut, Blob *pMsg){
  int n, i, k;
  char zBuf[100];
  n = blob_size(pMsg);
  for(i=0; i<n; i+=54){
    k = translateBase64(blob_buffer(pMsg)+i, i+54<n ? 54 : n-i, zBuf);
    blob_append(pOut, zBuf, k);
    blob_append(pOut, "\r\n", 2);
  }
}

#if defined(_WIN32) || defined(WIN32)
# undef popen
# define popen _popen
# undef pclose
# define pclose _pclose
#endif

/*
** Send an email message using whatever sending mechanism is configured
** by these settings:
**
**   email-send-method    "off"   Do not send any emails
**                        "pipe"  Pipe the email to email-send-command
**                        "db"    Store the mail in database email-send-db
**                        "file"  Store the email as a file in email-send-dir 
**
** The recepient(s) must be specified using  "To:" or "Cc:" or "Bcc:" fields
** in the header.  Likewise, the header must contains a "Subject:" line.
** The header might also include fields like "Message-Id:" or
** "In-Reply-To:".
**
** This routine will add fields to the header as follows:
**
**     From:
**     Content-Type:
**     Content-Transfer-Encoding:
**     
** At least one body must be supplied.
**
** The caller maintains ownership of the input Blobs.  This routine will
** read the Blobs and send them onward to the email system, but it will
** not free them.
**
** If zDest is not NULL then it is an overwrite for the email-send-method.
** zDest can be "stdout" to send output to the console for debugging.
*/
void email_send(Blob *pHdr, Blob *pPlain, Blob *pHtml, const char *zDest){
  const char *zFrom = db_get("email-self", 0);
  char *zBoundary = 0;
  Blob all;
  if( zFrom==0 ){
    fossil_warning("Missing configuration: \"email-self\"");
    return;
  }
  if( zDest==0 ) zDest = db_get("email-send-method", "off");
  if( strcmp(zDest, "off")==0 ){
    return;
  }
  blob_init(&all, 0, 0);
  blob_append(&all, blob_buffer(pHdr), blob_size(pHdr));
  blob_appendf(&all, "From: %s\r\n", zFrom);
  if( pPlain && pHtml ){
    blob_appendf(&all, "MIME-Version: 1.0\r\n");
    zBoundary = db_text(0, "SELECT hex(randomblob(20))");
    blob_appendf(&all, "Content-Type: multipart/alternative;"
                       " boundary=\"%s\"\r\n", zBoundary);
  }
  if( pPlain ){
    if( zBoundary ){
      blob_appendf(&all, "\r\n--%s\r\n", zBoundary);
    }
    blob_appendf(&all,"Content-Type: text/plain\r\n");
    blob_appendf(&all, "Content-Transfer-Encoding: base64\r\n\r\n");
    append_base64(&all, pPlain);
  }
  if( pHtml ){
    if( zBoundary ){
      blob_appendf(&all, "--%s\r\n", zBoundary);
    }
    blob_appendf(&all,"Content-Type: text/html\r\n");
    blob_appendf(&all, "Content-Transfer-Encoding: base64\r\n\r\n");
    append_base64(&all, pHtml);
  }
  if( zBoundary ){
    blob_appendf(&all, "--%s--\r\n", zBoundary);
    fossil_free(zBoundary);
    zBoundary = 0;
  }
  if( strcmp(zDest, "db")==0 ){
    sqlite3 *db;
    sqlite3_stmt *pStmt;
    int rc;
    const char *zDb = db_get("email-send-db",0);
    rc = sqlite3_open(zDb, &db);
    if( rc==SQLITE_OK ){
      sqlite3_exec(db, "CREATE TABLE IF NOT EXISTS email(\n"
                       "  emailid INTEGER PRIMARY KEY,\n"
                       "  msg TEXT\n);", 0, 0, 0);
      rc = sqlite3_prepare_v2(db, "INSERT INTO email(msg) VALUES(?1)", -1,
                              &pStmt, 0);
      if( rc==SQLITE_OK ){
        sqlite3_bind_text(pStmt, 1, blob_str(&all), -1, SQLITE_TRANSIENT);
        sqlite3_step(pStmt);
        sqlite3_finalize(pStmt);
      }
      sqlite3_close(db);
    }
  }else if( strcmp(zDest, "pipe")==0 ){
    const char *zCmd = db_get("email-send-command", 0);
    if( zCmd ){
      FILE *out = popen(zCmd, "w");
      if( out ){
        fwrite(blob_buffer(&all), 1, blob_size(&all), out);
        fclose(out);
      }
    }
  }else if( strcmp(zDest, "dir")==0 ){
    const char *zDir = db_get("email-send-dir","./");
    char *zFile = db_text(0,
        "SELECT %Q||strftime('/%%Y%%m%%d%%H%%M%%S','now')||hex(randomblob(8))",
        zDir);
    blob_write_to_file(&all, zFile);
    fossil_free(zFile);
  }else if( strcmp(zDest, "stdout")==0 ){
    fossil_print("%s\n", blob_str(&all));
  }
  blob_zero(&all);
}

/*
** SETTING: email-send-method         width=5 default=off
** Determine the method used to send email.  Allowed values are
** "off", "pipe", "dir", "db", and "stdout".  The "off" value means
** no email is ever sent.  The "pipe" value means email messages are
** piped into a command determined by the email-send-command setting.
** The "dir" value means emails are written to individual files in a
** directory determined by the email-send-dir setting.  The "db" value
** means that emails are added to an SQLite database named by the
** email-send-db setting.  The "stdout" value writes email text to
** standard output, for debugging.
*/
/*
** SETTING: email-send-command       width=40
** This is a command to which outbound email content is piped when the
** email-send-method is set to "pipe".  The command must extract
** recipient, sender, subject, and all other relevant information
** from the email header.
*/
/*
** SETTING: email-send-dir           width=40
** This is a directory into which outbound emails are written as individual
** files if the email-send-method is set to "dir".
*/
/*
** SETTING: email-send-db            width=40
** This is an SQLite database file into which outbound emails are written
** if the email-send-method is set to "db".
*/
/*
** SETTING: email-self               width=40
** This is the email address for the repository.  Outbound emails add
** this email address as the "From:" field.
*/


/*
** COMMAND: email
** 
** Usage: %fossil email SUBCOMMAND ARGS...
**
** Subcommands:
**
**    reset                   Hard reset of all email notification tables
**                            in the repository.  This erases all subscription
**                            information.  Use with extreme care.
**
**    send TO [OPTIONS]       Send a single email message using whatever
**                            email sending mechanism is currently configured.
**                            Use this for testing the email configuration.
**                            Options:
**
**                              --body FILENAME
**                              --html
**                              --stdout
**                              --subject|-S SUBJECT
**
**    settings [NAME VALUE]   With no arguments, list all email settings.
**                            Or change the value of a single email setting.
*/
void email_cmd(void){
  const char *zCmd;
  int nCmd;
  db_find_and_open_repository(0, 0);
  email_schema();
  zCmd = g.argc>=3 ? g.argv[2] : "x";
  nCmd = (int)strlen(zCmd);
  if( strncmp(zCmd, "reset", nCmd)==0 ){
    Blob yn;
    int c;
    fossil_print(
        "This will erase all content in the repository tables, thus\n"
        "deleting all subscriber information.  The information will be\n"
        "unrecoverable.\n");
    prompt_user("Continue? (y/N) ", &yn);
    c = blob_str(&yn)[0];
    if( c=='y' ){
      db_multi_exec(
        "DROP TABLE IF EXISTS subscriber;\n"
        "DROP TABLE IF EXISTS subscription;\n"
        "DROP TABLE IF EXISTS email_pending;\n"
        "DROP TABLE IF EXISTS email_bounce;\n"
      );
      email_schema();
    }
    blob_zero(&yn);
  }else
  if( strncmp(zCmd, "send", nCmd)==0 ){
    Blob prompt, body, hdr;
    int sendAsBoth = find_option("both",0,0)!=0;
    int sendAsHtml = find_option("html",0,0)!=0;
    const char *zDest = find_option("stdout",0,0)!=0 ? "stdout" : 0;
    int i;
    const char *zSubject = find_option("subject", "S", 1);
    const char *zSource = find_option("body", 0, 1);
    verify_all_options();
    blob_init(&prompt, 0, 0);
    blob_init(&body, 0, 0);
    blob_init(&hdr, 0, 0);
    for(i=3; i<g.argc; i++){
      blob_appendf(&hdr, "To: %s\n", g.argv[i]);
    }
    if( zSubject ){
      blob_appendf(&hdr, "Subject: %s\n", zSubject);
    }
    if( zSource ){
      blob_read_from_file(&body, zSource, ExtFILE);
    }else{
      prompt_for_user_comment(&body, &prompt);
    }
    if( sendAsHtml ){
      email_send(&hdr, 0, &body, zDest);
    }else if( sendAsBoth ){
      Blob html;
      blob_init(&html, 0, 0);
      blob_appendf(&html, "<pre>\n%h</pre>\n", blob_str(&body));
      email_send(&hdr, &body, &html, zDest);
      blob_zero(&html);
    }else{
      email_send(&hdr, &body, 0, zDest);
    }
    blob_zero(&hdr);
    blob_zero(&body);
    blob_zero(&prompt);
  }
  else if( strncmp(zCmd, "settings", nCmd)==0 ){
    int isGlobal = find_option("global",0,0)!=0;
    int i;
    int nSetting;
    const Setting *pSetting = setting_info(&nSetting);
    db_open_config(1, 0);
    verify_all_options();
    if( g.argc!=3 && g.argc!=5 ) usage("setting [NAME VALUE]");
    if( g.argc==5 ){
      const char *zLabel = g.argv[3];
      if( strncmp(zLabel, "email-", 6)!=0
       || (pSetting = db_find_setting(zLabel, 1))==0 ){
        fossil_fatal("not a valid email setting: \"%s\"", zLabel);
      }
      db_set(pSetting->name, g.argv[4], isGlobal);
      g.argc = 3;
    }
    pSetting = setting_info(&nSetting);
    for(; nSetting>0; nSetting--, pSetting++ ){
      if( strncmp(pSetting->name,"email-",6)!=0 ) continue;
      print_setting(pSetting);
    }
  }
  else{
    usage("reset|send|setting");
  }
}

/*
** WEBPAGE: subscribe
**
** Allow users to subscribe to email notifications, or to change or
** verify their subscription.
*/
void subscribe_page(void){
  int needCaptcha;
  unsigned int uSeed;
  const char *zDecoded;
  char *zCaptcha;

  login_check_credentials();
  if( !g.perm.EmailAlert ){
    login_needed(g.anon.EmailAlert);
    return;
  }
  style_header("Email Subscription");
  needCaptcha = P("usecaptcha")!=0 || login_is_nobody()
                  || login_is_special(g.zLogin);
  form_begin(0, "%R/subscribe");
  @ <table class="subscribe">
  @ <tr>
  @  <td class="form_label">Email&nbsp;Address:</td>
  @  <td><input type="text" name="e" value="" size="30"></td>
  @  <td></td>
  @ </tr>
  if( needCaptcha ){
    uSeed = captcha_seed();
    zDecoded = captcha_decode(uSeed);
    zCaptcha = captcha_render(zDecoded);
    @ <tr>
    @  <td class="form_label">Security Code:</td>
    @  <td><input type="text" name="captcha" value="" size="30">
    @  <input type="hidden" name="usecaptcha" value="1"></td>
    @  <input type="hidden" name="captchaseed" value="%u(uSeed)"></td>
    @  <td><span class="optionalTag">(copy from below)</span></td>
    @ </tr>
  }
  @ <tr>
  @  <td class="form_label">Nickname:</td>
  @  <td><input type="text" name="nn" value="" size="30"></td>
  @  <td><span class="optionalTag">(optional)</span></td>
  @ </tr>
  @ <tr>
  @  <td class="form_label">Password:</td>
  @  <td><input type="password" name="pw" value="" size="30"></td>
  @  <td><span class="optionalTag">(optional)</span></td>
  @ </tr>
  @ <tr>
  @  <td class="form_label">Options:</td>
  @  <td><label><input type="checkbox" name="sa" value="0">\
  @  Announcements</label><br>
  @  <label><input type="checkbox" name="sc" value="0">\
  @  Check-ins</label><br>
  @  <label><input type="checkbox" name="st" value="0">\
  @  Ticket changes</label><br>
  @  <label><input type="checkbox" name="sw" value="0">\
  @  Wiki</label><br>
  @  <label><input type="checkbox" name="di" value="0">\
  @  Daily digest only</label><br></td>
  @ </tr>
  @ <tr>
  @  <td></td>
  @  <td><input type="submit" value="Submit"></td>
  @ </tr>
  @ </table>
  if( needCaptcha ){
    @ <div class="captcha"><table class="captcha"><tr><td><pre>
    @ %h(zCaptcha)
    @ </pre>
    @ Enter the 8 characters above in the "Security Code" box
    @ </td></tr></table></div>
  }
  @ </form>
  style_footer();
}