/*
** Copyright (c) 2018 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/
**
*******************************************************************************
**
** Logic for email notification, also known as "alerts" or "subscriptions".
**
** Are you looking for the code that reads and writes the internet
** email protocol? That is not here. See the "smtp.c" file instead.
** Yes, the choice of source code filenames is not the greatest, but
** it is not so bad that changing them seems justified.
*/
#include "config.h"
#include "alerts.h"
#include <assert.h>
#include <time.h>
/*
** Maximum size of the subscriberCode blob, in bytes
*/
#define SUBSCRIBER_CODE_SZ 32
/*
** SQL code to implement the tables needed by the email notification
** system.
*/
static const char zAlertInit[] =
@ DROP TABLE IF EXISTS repository.subscriber;
@ -- 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
@ -- f - Forum posts
@ -- k - ** Special: Unsubscribed using /oneclickunsub
@ -- n - New forum threads
@ -- r - Replies to my own forum posts
@ -- t - Ticket changes
@ -- w - Wiki changes
@ -- x - Edits to forum posts
@ -- 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 BLOB DEFAULT (randomblob(32)) UNIQUE, -- UUID for subscriber
@ semail TEXT UNIQUE COLLATE nocase,-- email address
@ suname TEXT, -- corresponding USER entry
@ sverified BOOLEAN DEFAULT true, -- email address verified
@ sdonotcall BOOLEAN, -- true for Do Not Call
@ sdigest BOOLEAN, -- true for daily digests only
@ ssub TEXT, -- baseline subscriptions
@ sctime INTDATE, -- When this entry was created. unixtime
@ mtime INTDATE, -- Last change. unixtime
@ smip TEXT, -- IP address of last change
@ lastContact INT -- Last contact. days since 1970
@ );
@ CREATE INDEX repository.subscriberUname
@ ON subscriber(suname) WHERE suname IS NOT NULL;
@
@ DROP TABLE IF EXISTS repository.pending_alert;
@ -- Email notifications that need to be sent.
@ --
@ -- The first character of the eventid determines the event type.
@ -- Remaining characters determine the specific event. For example,
@ -- 'c4413' means check-in with rid=4413.
@ --
@ CREATE TABLE repository.pending_alert(
@ eventid TEXT PRIMARY KEY, -- Object that changed
@ sentSep BOOLEAN DEFAULT false, -- individual alert sent
@ sentDigest BOOLEAN DEFAULT false, -- digest alert sent
@ sentMod BOOLEAN DEFAULT false -- pending moderation alert sent
@ ) WITHOUT ROWID;
@
@ -- Obsolete table. No longer used.
@ DROP TABLE IF EXISTS repository.alert_bounce;
;
/*
** Return true if the email notification tables exist.
*/
int alert_tables_exist(void){
return db_table_exists("repository", "subscriber");
}
/*
** Record the fact that user zUser has made contact with the repository.
** This resets the subscription timeout on that user.
*/
void alert_user_contact(const char *zUser){
if( db_table_has_column("repository","subscriber","lastContact") ){
db_unprotect(PROTECT_READONLY);
db_multi_exec(
"UPDATE subscriber SET lastContact=now()/86400 WHERE suname=%Q",
zUser
);
db_protect_pop();
}
}
/*
** Make sure the table needed for email notification exist in the repository.
**
** If the bOnlyIfEnabled option is true, then tables are only created
** if the email-send-method is something other than "off".
*/
void alert_schema(int bOnlyIfEnabled){
if( !alert_tables_exist() ){
if( bOnlyIfEnabled
&& fossil_strcmp(db_get("email-send-method",0),"off")==0
){
return; /* Don't create table for disabled email */
}
db_exec_sql(zAlertInit);
return;
}
if( db_table_has_column("repository","subscriber","lastContact") ){
return;
}
db_unprotect(PROTECT_READONLY);
db_multi_exec(
"DROP TABLE IF EXISTS repository.alert_bounce;\n"
"ALTER TABLE repository.subscriber ADD COLUMN lastContact INT;\n"
"UPDATE subscriber SET lastContact=mtime/86400;"
);
db_protect_pop();
if( db_table_has_column("repository","pending_alert","sentMod") ){
return;
}
db_multi_exec(
"ALTER TABLE repository.pending_alert"
" ADD COLUMN sentMod BOOLEAN DEFAULT false;"
);
}
/*
** Process deferred alert events. Return the number of errors.
*/
static int alert_process_deferred_triggers(void){
if( db_table_exists("temp","deferred_chat_events")
&& db_table_exists("repository","chat")
){
const char *zChatUser = db_get("chat-timeline-user", 0);
if( zChatUser && zChatUser[0] ){
db_multi_exec(
"INSERT INTO chat(mtime,lmtime,xfrom,xmsg)"
" SELECT julianday(), "
" strftime('%%Y-%%m-%%dT%%H:%%M:%%S','now','localtime'),"
" %Q,"
" chat_msg_from_event(type, objid, user, comment)\n"
" FROM deferred_chat_events;\n",
zChatUser
);
}
}
return 0;
}
/*
** Enable triggers that automatically populate the pending_alert
** table. (Later:) Also add triggers that automatically relay timeline
** events to chat, if chat is configured for that.
*/
void alert_create_trigger(void){
if( db_table_exists("repository","pending_alert") ){
db_multi_exec(
"DROP TRIGGER IF EXISTS repository.alert_trigger1;\n" /* Purge legacy */
"CREATE TRIGGER temp.alert_trigger1\n"
"AFTER INSERT ON repository.event BEGIN\n"
" INSERT INTO pending_alert(eventid)\n"
" SELECT printf('%%.1c%%d',new.type,new.objid) WHERE true\n"
" ON CONFLICT(eventId) DO NOTHING;\n"
"END;"
);
}
if( db_table_exists("repository","chat")
&& db_get("chat-timeline-user", "")[0]!=0
){
/* Record events that will be relayed to chat, but do not relay
** them immediately, as the chat_msg_from_event() function requires
** that TAGXREF be up-to-date, and that has not happened yet when
** the insert into the EVENT table occurs. Make arrangements to
** invoke alert_process_deferred_triggers() when the transaction
** commits. The TAGXREF table will be ready by then. */
db_multi_exec(
"CREATE TABLE temp.deferred_chat_events(\n"
" type TEXT,\n"
" objid INT,\n"
" user TEXT,\n"
" comment TEXT\n"
");\n"
"CREATE TRIGGER temp.chat_trigger1\n"
"AFTER INSERT ON repository.event BEGIN\n"
" INSERT INTO deferred_chat_events"
" VALUES(new.type,new.objid,new.user,new.comment);\n"
"END;\n"
);
db_commit_hook(alert_process_deferred_triggers, 1);
}
}
/*
** Disable triggers the event_pending and chat triggers.
**
** This must be called before rebuilding the EVENT table, for example
** via the "fossil rebuild" command.
*/
void alert_drop_trigger(void){
db_multi_exec(
"DROP TRIGGER IF EXISTS temp.alert_trigger1;\n"
"DROP TRIGGER IF EXISTS repository.alert_trigger1;\n" /* Purge legacy */
"DROP TRIGGER IF EXISTS temp.chat_trigger1;\n"
);
}
/*
** Return true if email alerts are active.
*/
int alert_enabled(void){
if( !alert_tables_exist() ) return 0;
if( fossil_strcmp(db_get("email-send-method",0),"off")==0 ) return 0;
return 1;
}
/*
** If alerts are enabled, removes the pending_alert entry which
** matches (eventType || rid). Note that pending_alert entries are
** added via the manifest crosslinking process, so this has no effect
** if called before crosslinking is performed. Because alerts are sent
** asynchronously, unqueuing needs to be performed as part of the
** transaction in which crosslinking is performed in order to avoid a
** race condition.
*/
void alert_unqueue(char eventType, int rid){
if( alert_enabled() ){
db_multi_exec("DELETE FROM pending_alert WHERE eventid='%c%d'",
eventType, rid);
}
}
/*
** If the subscriber table does not exist, then paint an error message
** web page and return true.
**
** If the subscriber table does exist, return 0 without doing anything.
*/
static int alert_webpages_disabled(void){
if( alert_tables_exist() ) return 0;
style_set_current_feature("alerts");
style_header("Email Alerts Are Disabled");
@ <p>Email alerts are disabled on this server</p>
style_finish_page();
return 1;
}
/*
** Insert a "Subscriber List" submenu link if the current user
** is an administrator.
*/
void alert_submenu_common(void){
if( g.perm.Admin ){
if( fossil_strcmp(g.zPath,"subscribers") ){
style_submenu_element("Subscribers","%R/subscribers");
}
if( fossil_strcmp(g.zPath,"subscribe") ){
style_submenu_element("Add New Subscriber","%R/subscribe");
}
}
}
/*
** WEBPAGE: setup_notification
**
** Administrative page for configuring and controlling email notification.
** Normally accessible via the /Admin/Notification menu.
*/
void setup_notification(void){
static const char *const azSendMethods[] = {
"off", "Disabled",
"pipe", "Pipe to a command",
"db", "Store in a database",
"dir", "Store in a directory",
"relay", "SMTP relay"
};
login_check_credentials();
if( !g.perm.Setup ){
login_needed(0);
return;
}
db_begin_transaction();
alert_submenu_common();
style_submenu_element("Send Announcement","%R/announce");
style_set_current_feature("alerts");
style_header("Email Notification Setup");
@ <h1>Status</h1>
@ <table class="label-value">
if( alert_enabled() ){
stats_for_email();
}else{
@ <th>Disabled</th>
}
@ </table>
@ <hr>
@ <h1> Configuration </h1>
@ <form action="%R/setup_notification" method="post"><div>
@ <input type="submit" name="submit" value="Apply Changes"><hr>
login_insert_csrf_secret();
entry_attribute("Canonical Server URL", 40, "email-url",
"eurl", "", 0);
@ <p><b>Required.</b>
@ This URL is used as the basename for hyperlinks included in
@ email alert text. Omit the trailing "/".
@ Suggested value: "%h(g.zBaseURL)"
@ (Property: "email-url")</p>
@ <hr>
entry_attribute("Administrator email address", 40, "email-admin",
"eadmin", "", 0);
@ <p>This is the email for the human administrator for the system.
@ Abuse and trouble reports and password reset requests are send here.
@ (Property: "email-admin")</p>
@ <hr>
entry_attribute("\"Return-Path\" email address", 20, "email-self",
"eself", "", 0);
@ <p><b>Required.</b>
@ This is the email to which email notification bounces should be sent.
@ In cases where the email notification does not align with a specific
@ Fossil login account (for example, digest messages), this is also
@ the "From:" address of the email notification.
@ 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("List-ID", 40, "email-listid",
"elistid", "", 0);
@ <p>
@ If this is not an empty string, then it becomes the argument to
@ a "List-ID:" header on all out-bound notification emails.
@ (Property: "email-listid")</p>
@ <hr>
entry_attribute("Repository Nickname", 16, "email-subname",
"enn", "", 0);
@ <p><b>Required.</b>
@ This is short name used to identifies the repository in the
@ Subject: line of email alerts. Traditionally this name is
@ included in square brackets. Examples: "[fossil-src]", "[sqlite-src]".
@ (Property: "email-subname")</p>
@ <hr>
entry_attribute("Subscription Renewal Interval In Days", 8,
"email-renew-interval", "eri", "", 0);
@ <p>
@ If this value is an integer N greater than or equal to 14, then email
@ notification subscriptions will be suspended N days after the last known
@ interaction with the user. This prevents sending notifications
@ to abandoned accounts. If a subscription comes within 7 days of expiring,
@ a separate email goes out with the daily digest that prompts the
@ subscriber to click on a link to the "/renew" webpage in order to
@ extend their subscription. Subscriptions never expire if this setting
@ is less than 14 or is an empty string.
@ (Property: "email-renew-interval")</p>
@ <hr>
multiple_choice_attribute("Email Send Method", "email-send-method", "esm",
"off", count(azSendMethods)/2, azSendMethods);
@ <p>How to send email. Requires auxiliary information from the fields
@ that follow. Hint: Use the <a href="%R/announce">/announce</a> page
@ to send test message to debug this setting.
@ (Property: "email-send-method")</p>
alert_schema(1);
entry_attribute("Pipe Email Text Into This Command", 60, "email-send-command",
"ecmd", "sendmail -ti", 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,
@ recipient addresses, and subject from the header of the piped email
@ text. (Property: "email-send-command")</p>
entry_attribute("Store Emails In This Database", 60, "email-send-db",
"esdb", "", 0);
@ <p>When the send method is "store in a database", each email message is
@ stored in an SQLite database file with the name given here.
@ (Property: "email-send-db")</p>
entry_attribute("Store Emails In This Directory", 60, "email-send-dir",
"esdir", "", 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>
entry_attribute("SMTP Relay Host", 60, "email-send-relayhost",
"esrh", "", 0);
@ <p>When the send method is "SMTP relay", each email message is
@ transmitted via the SMTP protocol (rfc5321) to a "Mail Submission
@ Agent" or "MSA" (rfc4409) at the hostname shown here. Optionally
@ append a colon and TCP port number (ex: smtp.example.com:587).
@ The default TCP port number is 25.
@ (Property: "email-send-relayhost")</p>
@ <hr>
@ <p><input type="submit" name="submit" value="Apply Changes"></p>
@ </div></form>
db_end_transaction(0);
style_finish_page();
}
#if 0
/*
** 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);
}
}
#endif
/*
** Encode pMsg using the quoted-printable email encoding and
** append it onto pOut
*/
static void append_quoted(Blob *pOut, Blob *pMsg){
char *zIn = blob_str(pMsg);
char c;
int iCol = 0;
while( (c = *(zIn++))!=0 ){
if( (c>='!' && c<='~' && c!='=' && c!=':')
|| (c==' ' && zIn[0]!='\r' && zIn[0]!='\n')
){
blob_append_char(pOut, c);
iCol++;
if( iCol>=70 ){
blob_append(pOut, "=\r\n", 3);
iCol = 0;
}
}else if( c=='\r' && zIn[0]=='\n' ){
zIn++;
blob_append(pOut, "\r\n", 2);
iCol = 0;
}else if( c=='\n' ){
blob_append(pOut, "\r\n", 2);
iCol = 0;
}else{
char x[3];
x[0] = '=';
x[1] = "0123456789ABCDEF"[(c>>4)&0xf];
x[2] = "0123456789ABCDEF"[c&0xf];
blob_append(pOut, x, 3);
iCol += 3;
}
}
}
#if INTERFACE
/*
** An instance of the following object is used to send emails.
*/
struct AlertSender {
sqlite3 *db; /* Database emails are sent to */
sqlite3_stmt *pStmt; /* Stmt to insert into the database */
const char *zDest; /* How to send email. */
const char *zDb; /* Name of database file */
const char *zDir; /* Directory in which to store as email files */
const char *zCmd; /* Command to run for each email */
const char *zFrom; /* Emails come from here */
const char *zListId; /* Argument to List-ID header */
SmtpSession *pSmtp; /* SMTP relay connection */
Blob out; /* For zDest=="blob" */
char *zErr; /* Error message */
u32 mFlags; /* Flags */
int bImmediateFail; /* On any error, call fossil_fatal() */
};
/* Allowed values for mFlags to alert_sender_new().
*/
#define ALERT_IMMEDIATE_FAIL 0x0001 /* Call fossil_fatal() on any error */
#define ALERT_TRACE 0x0002 /* Log sending process on console */
#endif /* INTERFACE */
/*
** Shutdown an emailer. Clear all information other than the error message.
*/
static void emailerShutdown(AlertSender *p){
sqlite3_finalize(p->pStmt);
p->pStmt = 0;
sqlite3_close(p->db);
p->db = 0;
p->zDb = 0;
p->zDir = 0;
p->zCmd = 0;
p->zListId = 0;
if( p->pSmtp ){
smtp_client_quit(p->pSmtp);
smtp_session_free(p->pSmtp);
p->pSmtp = 0;
}
blob_reset(&p->out);
}
/*
** Put the AlertSender into an error state.
*/
static void emailerError(AlertSender *p, const char *zFormat, ...){
va_list ap;
fossil_free(p->zErr);
va_start(ap, zFormat);
p->zErr = vmprintf(zFormat, ap);
va_end(ap);
emailerShutdown(p);
if( p->mFlags & ALERT_IMMEDIATE_FAIL ){
fossil_fatal("%s", p->zErr);
}
}
/*
** Free an email sender object
*/
void alert_sender_free(AlertSender *p){
if( p ){
emailerShutdown(p);
fossil_free(p->zErr);
fossil_free(p);
}
}
/*
** Get an email setting value. Report an error if not configured.
** Return 0 on success and one if there is an error.
*/
static int emailerGetSetting(
AlertSender *p, /* Where to report the error */
const char **pzVal, /* Write the setting value here */
const char *zName /* Name of the setting */
){
const char *z = db_get(zName, 0);
int rc = 0;
if( z==0 || z[0]==0 ){
emailerError(p, "missing \"%s\" setting", zName);
rc = 1;
}else{
*pzVal = z;
}
return rc;
}
/*
** Create a new AlertSender object.
**
** The method used for sending email is determined by various email-*
** settings, and especially email-send-method. The repository
** email-send-method can be overridden by the zAltDest argument to
** cause a different sending mechanism to be used. Pass "stdout" to
** zAltDest to cause all emails to be printed to the console for
** debugging purposes.
**
** The AlertSender object returned must be freed using alert_sender_free().
*/
AlertSender *alert_sender_new(const char *zAltDest, u32 mFlags){
AlertSender *p;
p = fossil_malloc(sizeof(*p));
memset(p, 0, sizeof(*p));
blob_init(&p->out, 0, 0);
p->mFlags = mFlags;
if( zAltDest ){
p->zDest = zAltDest;
}else{
p->zDest = db_get("email-send-method",0);
}
if( fossil_strcmp(p->zDest,"off")==0 ) return p;
if( emailerGetSetting(p, &p->zFrom, "email-self") ) return p;
p->zListId = db_get("email-listid", 0);
if( fossil_strcmp(p->zDest,"db")==0 ){
char *zErr;
int rc;
if( emailerGetSetting(p, &p->zDb, "email-send-db") ) return p;
rc = sqlite3_open(p->zDb, &p->db);
if( rc ){
emailerError(p, "unable to open output database file \"%s\": %s",
p->zDb, sqlite3_errmsg(p->db));
return p;
}
rc = sqlite3_exec(p->db, "CREATE TABLE IF NOT EXISTS email(\n"
" emailid INTEGER PRIMARY KEY,\n"
" msg TEXT\n);", 0, 0, &zErr);
if( zErr ){
emailerError(p, "CREATE TABLE failed with \"%s\"", zErr);
sqlite3_free(zErr);
return p;
}
rc = sqlite3_prepare_v2(p->db, "INSERT INTO email(msg) VALUES(?1)", -1,
&p->pStmt, 0);
if( rc ){
emailerError(p, "cannot prepare INSERT statement: %s",
sqlite3_errmsg(p->db));
return p;
}
}else if( fossil_strcmp(p->zDest, "pipe")==0 ){
emailerGetSetting(p, &p->zCmd, "email-send-command");
}else if( fossil_strcmp(p->zDest, "dir")==0 ){
emailerGetSetting(p, &p->zDir, "email-send-dir");
}else if( fossil_strcmp(p->zDest, "blob")==0 ){
blob_init(&p->out, 0, 0);
}else if( fossil_strcmp(p->zDest, "relay")==0 ){
const char *zRelay = 0;
emailerGetSetting(p, &zRelay, "email-send-relayhost");
if( zRelay ){
u32 smtpFlags = SMTP_DIRECT;
if( mFlags & ALERT_TRACE ) smtpFlags |= SMTP_TRACE_STDOUT;
p->pSmtp = smtp_session_new(domain_of_addr(p->zFrom), zRelay,
smtpFlags);
smtp_client_startup(p->pSmtp);
}
}
return p;
}
/*
** Scan the header of the email message in pMsg looking for the
** (first) occurrance of zField. Fill pValue with the content of
** that field.
**
** This routine initializes pValue. Any prior content of pValue is
** discarded (leaked).
**
** Return non-zero on success. Return 0 if no instance of the header
** is found.
*/
int email_header_value(Blob *pMsg, const char *zField, Blob *pValue){
int nField = (int)strlen(zField);
Blob line;
blob_rewind(pMsg);
blob_init(pValue,0,0);
while( blob_line(pMsg, &line) ){
int n, i;
char *z;
blob_trim(&line);
n = blob_size(&line);
if( n==0 ) return 0;
if( n<nField+1 ) continue;
z = blob_buffer(&line);
if( sqlite3_strnicmp(z, zField, nField)==0 && z[nField]==':' ){
for(i=nField+1; i<n && fossil_isspace(z[i]); i++){}
blob_init(pValue, z+i, n-i);
while( blob_line(pMsg, &line) ){
blob_trim(&line);
n = blob_size(&line);
if( n==0 ) break;
z = blob_buffer(&line);
if( !fossil_isspace(z[0]) ) break;
for(i=1; i<n && fossil_isspace(z[i]); i++){}
blob_append(pValue, " ", 1);
blob_append(pValue, z+i, n-i);
}
return 1;
}
}
return 0;
}
/*
** Determine whether or not the input string is a valid email address.
** Only look at character up to but not including the first \000 or
** the first cTerm character, whichever comes first.
**
** Return the length of the email addresss string in bytes if the email
** address is valid. If the email address is misformed, return 0.
*/
int email_address_is_valid(const char *z, char cTerm){
int i;
int nAt = 0;
int nDot = 0;
char c;
if( z[0]=='.' ) return 0; /* Local part cannot begin with "." */
for(i=0; (c = z[i])!=0 && c!=cTerm; i++){
if( fossil_isalnum(c) ){
/* Alphanumerics are always ok */
}else if( c=='@' ){
if( nAt ) return 0; /* Only a single "@" allowed */
if( i>64 ) return 0; /* Local part too big */
nAt = 1;
nDot = 0;
if( i==0 ) return 0; /* Disallow empty local part */
if( z[i-1]=='.' ) return 0; /* Last char of local cannot be "." */
if( z[i+1]=='.' || z[i+1]=='-' ){
return 0; /* Domain cannot begin with "." or "-" */
}
}else if( c=='-' ){
if( z[i+1]==cTerm ) return 0; /* Last character cannot be "-" */
}else if( c=='.' ){
if( z[i+1]=='.' ) return 0; /* Do not allow ".." */
if( z[i+1]==cTerm ) return 0; /* Domain may not end with . */
nDot++;
}else if( (c=='_' || c=='+') && nAt==0 ){
/* _ and + are ok in the local part */
}else{
return 0; /* Anything else is an error */
}
}
if( c!=cTerm ) return 0; /* Missing terminator */
if( nAt==0 ) return 0; /* No "@" found anywhere */
if( nDot==0 ) return 0; /* No "." in the domain */
return i;
}
/*
** Make a copy of the input string up to but not including the
** first cTerm character.
**
** Verify that the string to be copied really is a valid
** email address. If it is not, then return NULL.
**
** This routine is more restrictive than necessary. It does not
** allow comments, IP address, quoted strings, or certain uncommon
** characters. The only non-alphanumerics allowed in the local
** part are "_", "+", "-" and "+".
*/
char *email_copy_addr(const char *z, char cTerm ){
int i = email_address_is_valid(z, cTerm);
return i==0 ? 0 : mprintf("%.*s", i, z);
}
/*
** Scan the input string for a valid email address that may be
** enclosed in <...>, or delimited by ',' or ':' or '=' or ' '.
** If the string contains one or more email addresses, extract the first
** one into memory obtained from mprintf() and return a pointer to it.
** If no valid email address can be found, return NULL.
*/
char *alert_find_emailaddr(const char *zIn){
char *zOut = 0;
do{
zOut = email_copy_addr(zIn, zIn[strcspn(zIn, ">,:= ")]);
if( zOut!=0 ) break;
zIn = (const char *)strpbrk(zIn, "<,:= ");
if( zIn==0 ) break;
zIn++;
}while( zIn!=0 );
return zOut;
}
/*
** SQL function: find_emailaddr(X)
**
** Return the first valid email address of the form <...> in input string
** X. Or return NULL if not found.
*/
void alert_find_emailaddr_func(
sqlite3_context *context,
int argc,
sqlite3_value **argv
){
const char *zIn = (const char*)sqlite3_value_text(argv[0]);
char *zOut = alert_find_emailaddr(zIn);
if( zOut ){
sqlite3_result_text(context, zOut, -1, fossil_free);
}
}
/*
** SQL function: display_name(X)
**
** If X is a string, search for a user name at the beginning of that
** string. The user name must be followed by an email address. If
** found, return the user name. If not found, return NULL.
**
** This routine is used to extract the display name from the USER.INFO
** field.
*/
void alert_display_name_func(
sqlite3_context *context,
int argc,
sqlite3_value **argv
){
const char *zIn = (const char*)sqlite3_value_text(argv[0]);
int i;
if( zIn==0 ) return;
while( fossil_isspace(zIn[0]) ) zIn++;
for(i=0; zIn[i] && zIn[i]!='<' && zIn[i]!='\n'; i++){}
if( zIn[i]=='<' ){
while( i>0 && fossil_isspace(zIn[i-1]) ){ i--; }
if( i>0 ){
sqlite3_result_text(context, zIn, i, SQLITE_TRANSIENT);
}
}
}
/*
** Return the hostname portion of an email address - the part following
** the @
*/
char *alert_hostname(const char *zAddr){
char *z = strchr(zAddr, '@');
if( z ){
z++;
}else{
z = (char*)zAddr;
}
return z;
}
/*
** Return a pointer to a fake email mailbox name that corresponds
** to human-readable name zFromName. The fake mailbox name is based
** on a hash. No huge problems arise if there is a hash collisions,
** but it is still better if collisions can be avoided.
**
** The returned string is held in a static buffer and is overwritten
** by each subsequent call to this routine.
*/
static char *alert_mailbox_name(const char *zFromName){
static char zHash[20];
unsigned int x = 0;
int n = 0;
while( zFromName[0] ){
n++;
x = x*1103515245 + 12345 + ((unsigned char*)zFromName)[0];
zFromName++;
}
sqlite3_snprintf(sizeof(zHash), zHash,
"noreply%x%08x", n, x);
return zHash;
}
/*
** COMMAND: test-mailbox-hashname
**
** Usage: %fossil test-mailbox-hashname HUMAN-NAME ...
**
** Return the mailbox hash name corresponding to each human-readable
** name on the command line. This is a test interface for the
** alert_mailbox_name() function.
*/
void alert_test_mailbox_hashname(void){
int i;
for(i=2; i<g.argc; i++){
fossil_print("%30s: %s\n", g.argv[i], alert_mailbox_name(g.argv[i]));
}
}
/*
** Extract all To: header values from the email header supplied.
** Store them in the array list.
*/
void email_header_to(Blob *pMsg, int *pnTo, char ***pazTo){
int nTo = 0;
char **azTo = 0;
Blob v;
char *z, *zAddr;
int i;
email_header_value(pMsg, "to", &v);
z = blob_str(&v);
for(i=0; z[i]; i++){
if( z[i]=='<' && (zAddr = email_copy_addr(&z[i+1],'>'))!=0 ){
azTo = fossil_realloc(azTo, sizeof(azTo[0])*(nTo+1) );
azTo[nTo++] = zAddr;
}
}
*pnTo = nTo;
*pazTo = azTo;
}
/*
** Free a list of To addresses obtained from a prior call to
** email_header_to()
*/
void email_header_to_free(int nTo, char **azTo){
int i;
for(i=0; i<nTo; i++) fossil_free(azTo[i]);
fossil_free(azTo);
}
/*
** Send a single email message.
**
** The recipient(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:
** Date:
** Message-Id:
** Content-Type:
** Content-Transfer-Encoding:
** MIME-Version:
** Sender:
**
** 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.
**
** The Message-Id: field is added if there is not already a Message-Id
** in the pHdr parameter.
**
** If the zFromName argument is not NULL, then it should be a human-readable
** name or handle for the sender. In that case, "From:" becomes a made-up
** email address based on a hash of zFromName and the domain of email-self,
** and an additional "Sender:" field is inserted with the email-self
** address. Downstream software might use the Sender header to set
** the envelope-from address of the email. If zFromName is a NULL pointer,
** then the "From:" is set to the email-self value and Sender is
** omitted.
*/
void alert_send(
AlertSender *p, /* Emailer context */
Blob *pHdr, /* Email header (incomplete) */
Blob *pBody, /* Email body */
const char *zFromName /* Optional human-readable name of sender */
){
Blob all, *pOut;
u64 r1, r2;
if( p->mFlags & ALERT_TRACE ){
fossil_print("Sending email\n");
}
if( fossil_strcmp(p->zDest, "off")==0 ){
return;
}
blob_init(&all, 0, 0);
if( fossil_strcmp(p->zDest, "blob")==0 ){
pOut = &p->out;
if( blob_size(pOut) ){
blob_appendf(pOut, "%.72c\n", '=');
}
}else{
pOut = &all;
}
blob_append(pOut, blob_buffer(pHdr), blob_size(pHdr));
if( p->zFrom==0 || p->zFrom[0]==0 ){
return; /* email-self is not set. Error will be reported separately */
}else if( zFromName ){
blob_appendf(pOut, "From: %s <%s@%s>\r\n",
zFromName, alert_mailbox_name(zFromName), alert_hostname(p->zFrom));
blob_appendf(pOut, "Sender: <%s>\r\n", p->zFrom);
}else{
blob_appendf(pOut, "From: <%s>\r\n", p->zFrom);
}
blob_appendf(pOut, "Date: %z\r\n", cgi_rfc822_datestamp(time(0)));
if( p->zListId && p->zListId[0] ){
blob_appendf(pOut, "List-Id: %s\r\n", p->zListId);
}
if( strstr(blob_str(pHdr), "\r\nMessage-Id:")==0 ){
/* Message-id format: "<$(date)x$(random)@$(from-host)>" where $(date) is
** the current unix-time in hex, $(random) is a 64-bit random number,
** and $(from) is the domain part of the email-self setting. */
sqlite3_randomness(sizeof(r1), &r1);
r2 = time(0);
blob_appendf(pOut, "Message-Id: <%llxx%016llx@%s>\r\n",
r2, r1, alert_hostname(p->zFrom));
}
blob_add_final_newline(pBody);
blob_appendf(pOut, "MIME-Version: 1.0\r\n");
blob_appendf(pOut, "Content-Type: text/plain; charset=\"UTF-8\"\r\n");
#if 0
blob_appendf(pOut, "Content-Transfer-Encoding: base64\r\n\r\n");
append_base64(pOut, pBody);
#else
blob_appendf(pOut, "Content-Transfer-Encoding: quoted-printable\r\n\r\n");
append_quoted(pOut, pBody);
#endif
if( p->pStmt ){
int i, rc;
sqlite3_bind_text(p->pStmt, 1, blob_str(&all), -1, SQLITE_TRANSIENT);
for(i=0; i<100 && sqlite3_step(p->pStmt)==SQLITE_BUSY; i++){
sqlite3_sleep(10);
}
rc = sqlite3_reset(p->pStmt);
if( rc!=SQLITE_OK ){
emailerError(p, "Failed to insert email message into output queue.\n"
"%s", sqlite3_errmsg(p->db));
}
}else if( p->zCmd ){
FILE *out = popen(p->zCmd, "w");
if( out ){
fwrite(blob_buffer(&all), 1, blob_size(&all), out);
pclose(out);
}else{
emailerError(p, "Could not open output pipe \"%s\"", p->zCmd);
}
}else if( p->zDir ){
char *zFile = file_time_tempname(p->zDir, ".email");
blob_write_to_file(&all, zFile);
fossil_free(zFile);
}else if( p->pSmtp ){
char **azTo = 0;
int nTo = 0;
email_header_to(pHdr, &nTo, &azTo);
if( nTo>0 ){
smtp_send_msg(p->pSmtp, p->zFrom, nTo, (const char**)azTo,blob_str(&all));
email_header_to_free(nTo, azTo);
}
}else if( strcmp(p->zDest, "stdout")==0 ){
char **azTo = 0;
int nTo = 0;
int i;
email_header_to(pHdr, &nTo, &azTo);
for(i=0; i<nTo; i++){
fossil_print("X-To-Test-%d: [%s]\r\n", i, azTo[i]);
}
email_header_to_free(nTo, azTo);
blob_add_final_newline(&all);
fossil_print("%s", blob_str(&all));
}
blob_reset(&all);
}
/*
** SETTING: email-url width=40
** This is the main URL used to access the repository for cloning or
** syncing or for operating the web interface. It is also
** the basename for hyperlinks included in email alert text.
** Omit the trailing "/". If the repository is not intended to be
** a long-running server and will not be sending email notifications,
** then leave this setting blank.
*/
/*
** SETTING: email-admin width=40
** This is the email address for the human administrator for the system.
** Abuse and trouble reports and password reset requests are send here.
*/
/*
** SETTING: email-subname width=16
** This is a short name used to identifies the repository in the Subject:
** line of email alerts. Traditionally this name is included in square
** brackets. Examples: "[fossil-src]", "[sqlite-src]".
*/
/*
** SETTING: email-renew-interval width=16
** If this setting as an integer N that is 14 or greater then email
** notification is suspended for subscriptions that have a "last contact
** time" of more than N days ago. The "last contact time" is recorded
** in the SUBSCRIBER.LASTCONTACT entry of the database. Logging in,
** sending a forum post, editing a wiki page, changing subscription settings
** at /alerts, or visiting /renew all update the last contact time.
** If this setting is not an integer value or is less than 14 or undefined,
** then subscriptions never expire.
*/
/* X-VARIABLE: email-renew-warning
** X-VARIABLE: email-renew-cutoff
**
** These CONFIG table entries are not considered "settings" since their
** values are computed and updated automatically.
**
** email-renew-cutoff is the lastContact cutoff for subscription. It
** is measured in days since 1970-01-01. If The lastContact time for
** a subscription is less than email-renew-cutoff, then now new emails
** are sent to the subscriber.
**
** email-renew-warning is the time (in days since 1970-01-01) when the
** last batch of "your subscription is about to expire" emails were
** sent out.
**
** email-renew-cutoff is normally 7 days behind email-renew-warning.
*/
/*
** SETTING: email-send-method width=5 default=off sensitive
** Determine the method used to send email. Allowed values are
** "off", "relay", "pipe", "dir", "db", and "stdout". The "off" value
** means no email is ever sent. The "relay" value means emails are sent
** to an Mail Sending Agent using SMTP located at email-send-relayhost.
** 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 sensitive
** 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 sensitive
** 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 sensitive
** 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.
*/
/*
** SETTING: email-listid width=40
** If this setting is not an empty string, then it becomes the argument to
** a "List-ID:" header that is added to all out-bound notification emails.
*/
/*
** SETTING: email-send-relayhost width=40 sensitive
** This is the hostname and TCP port to which output email messages
** are sent when email-send-method is "relay". There should be an
** SMTP server configured as a Mail Submission Agent listening on the
** designated host and port and all times.
*/
/*
** COMMAND: alerts*
**
** Usage: %fossil alerts SUBCOMMAND ARGS...
**
** Subcommands:
**
** pending Show all pending alerts. Useful for debugging.
**
** reset Hard reset of all email notification tables
** in the repository. This erases all subscription
** information. ** Use with extreme care **
**
** send Compose and send pending email alerts.
** Some installations may want to do this via
** a cron-job to make sure alerts are sent
** in a timely manner.
**
** Options:
** --digest Send digests
** --renewal Send subscription renewal
** notices
** --test Write to standard output
**
** settings [NAME VALUE] With no arguments, list all email settings.
** Or change the value of a single email setting.
**
** status Report on the status of the email alert
** subsystem
**
** subscribers [PATTERN] List all subscribers matching PATTERN. Either
** LIKE or GLOB wildcards can be used in PATTERN.
**
** test-message TO [OPTS] Send a single email message using whatever
** email sending mechanism is currently configured.
** Use this for testing the email notification
** configuration.
**
** Options:
** --body FILENAME Content from FILENAME
** --smtp-trace Trace SMTP processing
** --stdout Send msg to stdout
** -S|--subject SUBJECT Message "subject:"
**
** unsubscribe EMAIL Remove a single subscriber with the given EMAIL.
*/
void alert_cmd(void){
const char *zCmd;
int nCmd;
db_find_and_open_repository(0, 0);
alert_schema(0);
zCmd = g.argc>=3 ? g.argv[2] : "x";
nCmd = (int)strlen(zCmd);
if( strncmp(zCmd, "pending", nCmd)==0 ){
Stmt q;
verify_all_options();
if( g.argc!=3 ) usage("pending");
db_prepare(&q,"SELECT eventid, sentSep, sentDigest, sentMod"
" FROM pending_alert");
while( db_step(&q)==SQLITE_ROW ){
fossil_print("%10s %7s %10s %7s\n",
db_column_text(&q,0),
db_column_int(&q,1) ? "sentSep" : "",
db_column_int(&q,2) ? "sentDigest" : "",
db_column_int(&q,3) ? "sentMod" : "");
}
db_finalize(&q);
}else
if( strncmp(zCmd, "reset", nCmd)==0 ){
int c;
int bForce = find_option("force","f",0)!=0;
verify_all_options();
if( bForce ){
c = 'y';
}else{
Blob yn;
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];
blob_reset(&yn);
}
if( c=='y' ){
alert_drop_trigger();
db_multi_exec(
"DROP TABLE IF EXISTS subscriber;\n"
"DROP TABLE IF EXISTS pending_alert;\n"
"DROP TABLE IF EXISTS alert_bounce;\n"
/* Legacy */
"DROP TABLE IF EXISTS alert_pending;\n"
"DROP TABLE IF EXISTS subscription;\n"
);
alert_schema(0);
}
}else
if( strncmp(zCmd, "send", nCmd)==0 ){
u32 eFlags = 0;
if( find_option("digest",0,0)!=0 ) eFlags |= SENDALERT_DIGEST;
if( find_option("renewal",0,0)!=0 ) eFlags |= SENDALERT_RENEWAL;
if( find_option("test",0,0)!=0 ){
eFlags |= SENDALERT_PRESERVE|SENDALERT_STDOUT;
}
verify_all_options();
alert_send_alerts(eFlags);
}else
if( strncmp(zCmd, "settings", nCmd)==0 ){
int isGlobal = find_option("global",0,0)!=0;
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/*works-like:""*/, 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, 0);
}
}else
if( strncmp(zCmd, "status", nCmd)==0 ){
Stmt q;
int iCutoff;
int nSetting, n;
static const char *zFmt = "%-29s %d\n";
const Setting *pSetting = setting_info(&nSetting);
db_open_config(1, 0);
verify_all_options();
if( g.argc!=3 ) usage("status");
pSetting = setting_info(&nSetting);
for(; nSetting>0; nSetting--, pSetting++ ){
if( strncmp(pSetting->name,"email-",6)!=0 ) continue;
print_setting(pSetting, 0);
}
n = db_int(0,"SELECT count(*) FROM pending_alert WHERE NOT sentSep");
fossil_print(zFmt/*works-like:"%s%d"*/, "pending-alerts", n);
n = db_int(0,"SELECT count(*) FROM pending_alert WHERE NOT sentDigest");
fossil_print(zFmt/*works-like:"%s%d"*/, "pending-digest-alerts", n);
db_prepare(&q,
"SELECT"
" name,"
" value,"
" now()/86400-value,"
" date(value*86400,'unixepoch')"
" FROM repository.config"
" WHERE name in ('email-renew-warning','email-renew-cutoff');");
while( db_step(&q)==SQLITE_ROW ){
fossil_print("%-29s %-6d (%d days ago on %s)\n",
db_column_text(&q, 0),
db_column_int(&q, 1),
db_column_int(&q, 2),
db_column_text(&q, 3));
}
db_finalize(&q);
n = db_int(0,"SELECT count(*) FROM subscriber");
fossil_print(zFmt/*works-like:"%s%d"*/, "total-subscribers", n);
iCutoff = db_get_int("email-renew-cutoff", 0);
n = db_int(0, "SELECT count(*) FROM subscriber WHERE sverified"
" AND NOT sdonotcall AND length(ssub)>1"
" AND lastContact>=%d", iCutoff);
fossil_print(zFmt/*works-like:"%s%d"*/, "active-subscribers", n);
}else
if( strncmp(zCmd, "subscribers", nCmd)==0 ){
Stmt q;
verify_all_options();
if( g.argc!=3 && g.argc!=4 ) usage("subscribers [PATTERN]");
if( g.argc==4 ){
char *zPattern = g.argv[3];
db_prepare(&q,
"SELECT semail FROM subscriber"
" WHERE semail LIKE '%%%q%%' OR suname LIKE '%%%q%%'"
" OR semail GLOB '*%q*' or suname GLOB '*%q*'"
" ORDER BY semail",
zPattern, zPattern, zPattern, zPattern);
}else{
db_prepare(&q,
"SELECT semail FROM subscriber"
" ORDER BY semail");
}
while( db_step(&q)==SQLITE_ROW ){
fossil_print("%s\n", db_column_text(&q, 0));
}
db_finalize(&q);
}else
if( strncmp(zCmd, "test-message", nCmd)==0 ){
Blob prompt, body, hdr;
const char *zDest = find_option("stdout",0,0)!=0 ? "stdout" : 0;
int i;
u32 mFlags = ALERT_IMMEDIATE_FAIL;
const char *zSubject = find_option("subject", "S", 1);
const char *zSource = find_option("body", 0, 1);
AlertSender *pSender;
if( find_option("smtp-trace",0,0)!=0 ) mFlags |= ALERT_TRACE;
verify_all_options();
blob_init(&prompt, 0, 0);
blob_init(&body, 0, 0);
blob_init(&hdr, 0, 0);
blob_appendf(&hdr,"To: ");
for(i=3; i<g.argc; i++){
if( i>3 ) blob_append(&hdr, ", ", 2);
blob_appendf(&hdr, "<%s>", g.argv[i]);
}
blob_append(&hdr,"\r\n",2);
if( zSubject==0 ) zSubject = "fossil alerts test-message";
blob_appendf(&hdr, "Subject: %s\r\n", zSubject);
if( zSource ){
blob_read_from_file(&body, zSource, ExtFILE);
}else{
prompt_for_user_comment(&body, &prompt);
}
blob_add_final_newline(&body);
pSender = alert_sender_new(zDest, mFlags);
alert_send(pSender, &hdr, &body, 0);
alert_sender_free(pSender);
blob_reset(&hdr);
blob_reset(&body);
blob_reset(&prompt);
}else
if( strncmp(zCmd, "unsubscribe", nCmd)==0 ){
verify_all_options();
if( g.argc!=4 ) usage("unsubscribe EMAIL");
db_multi_exec(
"DELETE FROM subscriber WHERE semail=%Q", g.argv[3]);
}else
{
usage("pending|reset|send|setting|status|"
"subscribers|test-message|unsubscribe");
}
}
/*
** Do error checking on a submitted subscription form. Return TRUE
** if the submission is valid. Return false if any problems are seen.
*/
static int subscribe_error_check(
int *peErr, /* Type of error */
char **pzErr, /* Error message text */
int needCaptcha /* True if captcha check needed */
){
const char *zEAddr;
int i, j, n;
char c;
*peErr = 0;
*pzErr = 0;
/* Verify the captcha first */
if( needCaptcha ){
if( !captcha_is_correct(1) ){
*peErr = 2;
*pzErr = mprintf("incorrect security code");
return 0;
}
}
/* Check the validity of the email address.
**
** (1) Exactly one '@' character.
** (2) No other characters besides [a-zA-Z0-9._+-]
**
** The local part is currently more restrictive than RFC 5322 allows:
** https://stackoverflow.com/a/2049510/142454 We will expand this as
** necessary.
*/
zEAddr = P("e");
if( zEAddr==0 ){
*peErr = 1;
*pzErr = mprintf("required");
return 0;
}
for(i=j=n=0; (c = zEAddr[i])!=0; i++){
if( c=='@' ){
n = i;
j++;
continue;
}
if( !fossil_isalnum(c) && c!='.' && c!='_' && c!='-' && c!='+' ){
*peErr = 1;
*pzErr = mprintf("illegal character in email address: 0x%x '%c'",
c, c);
return 0;
}
}
if( j!=1 ){
*peErr = 1;
*pzErr = mprintf("email address should contain exactly one '@'");
return 0;
}
if( n<1 ){
*peErr = 1;
*pzErr = mprintf("name missing before '@' in email address");
return 0;
}
if( n>i-5 ){
*peErr = 1;
*pzErr = mprintf("email domain too short");
return 0;
}
if( authorized_subscription_email(zEAddr)==0 ){
*peErr = 1;
*pzErr = mprintf("not an authorized email address");
return 0;
}
/* Check to make sure the email address is available for reuse */
if( db_exists("SELECT 1 FROM subscriber WHERE semail=%Q", zEAddr) ){
*peErr = 1;
*pzErr = mprintf("this email address is used by someone else");
return 0;
}
/* If we reach this point, all is well */
return 1;
}
/*
** Text of email message sent in order to confirm a subscription.
*/
static const char zConfirmMsg[] =
@ Someone has signed you up for email alerts on the Fossil repository
@ at %s.
@
@ To confirm your subscription and begin receiving alerts, click on
@ the following hyperlink:
@
@ %s/alerts/%s
@
@ Save the hyperlink above! You can reuse this same hyperlink to
@ unsubscribe or to change the kinds of alerts you receive.
@
@ If you do not want to subscribe, you can simply ignore this message.
@ You will not be contacted again.
@
;
/*
** Append the text of an email confirmation message to the given
** Blob. The security code is in zCode.
*/
void alert_append_confirmation_message(Blob *pMsg, const char *zCode){
blob_appendf(pMsg, zConfirmMsg/*works-like:"%s%s%s"*/,
g.zBaseURL, g.zBaseURL, zCode);
}
/*
** WEBPAGE: subscribe
**
** Allow users to subscribe to email notifications.
**
** This page is usually run by users who are not logged in.
** A logged-in user can add email notifications on the /alerts page.
** Access to this page by a logged in user (other than an
** administrator) results in a redirect to the /alerts page.
**
** Administrators can visit this page in order to sign up other
** users.
**
** The Alerts permission ("7") is required to access this
** page. To allow anonymous passers-by to sign up for email
** notification, set Email-Alerts on user "nobody" or "anonymous".
*/
void subscribe_page(void){
int needCaptcha;
unsigned int uSeed = 0;
const char *zDecoded;
char *zCaptcha = 0;
char *zErr = 0;
int eErr = 0;
int di;
if( alert_webpages_disabled() ) return;
login_check_credentials();
if( !g.perm.EmailAlert ){
login_needed(g.anon.EmailAlert);
return;
}
if( login_is_individual()
&& db_exists("SELECT 1 FROM subscriber WHERE suname=%Q",g.zLogin)
){
/* This person is already signed up for email alerts. Jump
** to the screen that lets them edit their alert preferences.
** Except, administrators can create subscriptions for others so
** do not jump for them.
*/
if( g.perm.Admin ){
/* Admins get a link to admin their own account, but they
** stay on this page so that they can create subscriptions
** for other people. */
style_submenu_element("My Subscription","%R/alerts");
}else{
/* Everybody else jumps to the page to administer their own
** account only. */
cgi_redirectf("%R/alerts");
return;
}
}
if( !g.perm.Admin && !db_get_boolean("anon-subscribe",1) ){
register_page();
return;
}
style_set_current_feature("alerts");
alert_submenu_common();
needCaptcha = !login_is_individual();
if( P("submit")
&& cgi_csrf_safe(2)
&& subscribe_error_check(&eErr,&zErr,needCaptcha)
){
/* A validated request for a new subscription has been received. */
char ssub[20];
const char *zEAddr = P("e");
const char *zCode; /* New subscriber code (in hex) */
int nsub = 0;
const char *suname = PT("suname");
if( suname==0 && needCaptcha==0 && !g.perm.Admin ) suname = g.zLogin;
if( suname && suname[0]==0 ) suname = 0;
if( PB("sa") ) ssub[nsub++] = 'a';
if( g.perm.Read && PB("sc") ) ssub[nsub++] = 'c';
if( g.perm.RdForum && PB("sf") ) ssub[nsub++] = 'f';
if( g.perm.RdForum && PB("sn") ) ssub[nsub++] = 'n';
if( g.perm.RdForum && PB("sr") ) ssub[nsub++] = 'r';
if( g.perm.RdTkt && PB("st") ) ssub[nsub++] = 't';
if( g.perm.RdWiki && PB("sw") ) ssub[nsub++] = 'w';
if( g.perm.RdForum && PB("sx") ) ssub[nsub++] = 'x';
ssub[nsub] = 0;
zCode = db_text(0,
"INSERT INTO subscriber(semail,suname,"
" sverified,sdonotcall,sdigest,ssub,sctime,mtime,smip,lastContact)"
"VALUES(%Q,%Q,%d,0,%d,%Q,now(),now(),%Q,now()/86400)"
"RETURNING hex(subscriberCode);",
/* semail */ zEAddr,
/* suname */ suname,
/* sverified */ needCaptcha==0,
/* sdigest */ PB("di"),
/* ssub */ ssub,
/* smip */ g.zIpAddr
);
if( !needCaptcha ){
/* The new subscription has been added on behalf of a logged-in user.
** No verification is required. Jump immediately to /alerts page.
*/
if( g.perm.Admin ){
cgi_redirectf("%R/alerts/%.32s", zCode);
}else{
cgi_redirectf("%R/alerts");
}
return;
}else{
/* We need to send a verification email */
Blob hdr, body;
AlertSender *pSender = alert_sender_new(0,0);
blob_init(&hdr,0,0);
blob_init(&body,0,0);
blob_appendf(&hdr, "To: <%s>\n", zEAddr);
blob_appendf(&hdr, "Subject: Subscription verification\n");
alert_append_confirmation_message(&body, zCode);
alert_send(pSender, &hdr, &body, 0);
style_header("Email Alert Verification");
if( pSender->zErr ){
@ <h1>Internal Error</h1>
@ <p>The following internal error was encountered while trying
@ to send the confirmation email:
@ <blockquote><pre>
@ %h(pSender->zErr)
@ </pre></blockquote>
}else{
@ <p>An email has been sent to "%h(zEAddr)". That email contains a
@ hyperlink that you must click to activate your
@ subscription.</p>
}
alert_sender_free(pSender);
style_finish_page();
}
return;
}
style_header("Signup For Email Alerts");
if( P("submit")==0 ){
/* If this is the first visit to this page (if this HTTP request did not
** come from a prior Submit of the form) then default all of the
** subscription options to "on" */
cgi_set_parameter_nocopy("sa","1",1);
if( g.perm.Read ) cgi_set_parameter_nocopy("sc","1",1);
if( g.perm.RdForum ) cgi_set_parameter_nocopy("sf","1",1);
if( g.perm.RdForum ) cgi_set_parameter_nocopy("sn","1",1);
if( g.perm.RdForum ) cgi_set_parameter_nocopy("sr","1",1);
if( g.perm.RdTkt ) cgi_set_parameter_nocopy("st","1",1);
if( g.perm.RdWiki ) cgi_set_parameter_nocopy("sw","1",1);
}
@ <p>To receive email notifications for changes to this
@ repository, fill out the form below and press the "Submit" button.</p>
form_begin(0, "%R/subscribe");
@ <table class="subscribe">
@ <tr>
@ <td class="form_label">Email Address:</td>
@ <td><input type="text" name="e" value="%h(PD("e",""))" size="30"></td>
@ <tr>
if( eErr==1 ){
@ <tr><td><td><span class='loginError'>↑ %h(zErr)</span></td></tr>
}
@ </tr>
if( needCaptcha ){
const char *zInit = "";
if( P("captchaseed")!=0 && eErr!=2 ){
uSeed = strtoul(P("captchaseed"),0,10);
zInit = P("captcha");
}else{
uSeed = captcha_seed();
}
zDecoded = captcha_decode(uSeed, 0);
zCaptcha = captcha_render(zDecoded);
@ <tr>
@ <td class="form_label">Security Code:</td>
@ <td><input type="text" name="captcha" value="%h(zInit)" size="30">
captcha_speakit_button(uSeed, "Speak the code");
@ <input type="hidden" name="captchaseed" value="%u(uSeed)"></td>
@ </tr>
if( eErr==2 ){
@ <tr><td><td><span class='loginError'>↑ %h(zErr)</span></td></tr>
}
@ </tr>
}
if( g.perm.Admin ){
@ <tr>
@ <td class="form_label">User:</td>
@ <td><input type="text" name="suname" value="%h(PD("suname",g.zLogin))" \
@ size="30"></td>
@ </tr>
if( eErr==3 ){
@ <tr><td><td><span class='loginError'>↑ %h(zErr)</span></td></tr>
}
@ </tr>
}
@ <tr>
@ <td class="form_label">Topics:</td>
@ <td><label><input type="checkbox" name="sa" %s(PCK("sa"))> \
@ Announcements</label><br>
if( g.perm.Read ){
@ <label><input type="checkbox" name="sc" %s(PCK("sc"))> \
@ Check-ins</label><br>
}
if( g.perm.RdForum ){
@ <label><input type="checkbox" name="sf" %s(PCK("sf"))> \
@ All Forum Posts</label><br>
@ <label><input type="checkbox" name="sn" %s(PCK("sn"))> \
@ New Forum Threads</label><br>
@ <label><input type="checkbox" name="sr" %s(PCK("sr"))> \
@ Replies To My Forum Posts</label><br>
@ <label><input type="checkbox" name="sx" %s(PCK("sx"))> \
@ Edits To Forum Posts</label><br>
}
if( g.perm.RdTkt ){
@ <label><input type="checkbox" name="st" %s(PCK("st"))> \
@ Ticket changes</label><br>
}
if( g.perm.RdWiki ){
@ <label><input type="checkbox" name="sw" %s(PCK("sw"))> \
@ Wiki</label><br>
}
di = PB("di");
@ </td></tr>
@ <tr>
@ <td class="form_label">Delivery:</td>
@ <td><select size="1" name="di">
@ <option value="0" %s(di?"":"selected")>Individual Emails</option>
@ <option value="1" %s(di?"selected":"")>Daily Digest</option>
@ </select></td>
@ </tr>
if( g.perm.Admin ){
@ <tr>
@ <td class="form_label">Admin Options:</td><td>
@ <label><input type="checkbox" name="vi" %s(PCK("vi"))> \
@ Verified</label><br>
@ <label><input type="checkbox" name="dnc" %s(PCK("dnc"))> \
@ Do not call</label></td></tr>
}
@ <tr>
@ <td></td>
if( needCaptcha && !alert_enabled() ){
@ <td><input type="submit" name="submit" value="Submit" disabled>
@ (Email current disabled)</td>
}else{
@ <td><input type="submit" name="submit" value="Submit"></td>
}
@ </tr>
@ </table>
if( needCaptcha ){
@ <div class="captcha"><table class="captcha"><tr><td><pre class="captcha">
@ %h(zCaptcha)
@ </pre>
@ Enter the 8 characters above in the "Security Code" box<br/>
@ </td></tr></table></div>
}
@ </form>
fossil_free(zErr);
style_finish_page();
}
/*
** Either shutdown or completely delete a subscription entry given
** by the hex value zName. Then paint a webpage that explains that
** the entry has been removed.
*/
static void alert_unsubscribe(int sid, int bTotal){
const char *zEmail = 0;
const char *zLogin = 0;
int uid = 0;
Stmt q;
db_prepare(&q, "SELECT semail, suname FROM subscriber"
" WHERE subscriberId=%d", sid);
if( db_step(&q)==SQLITE_ROW ){
zEmail = db_column_text(&q, 0);
zLogin = db_column_text(&q, 1);
uid = db_int(0, "SELECT uid FROM user WHERE login=%Q", zLogin);
}
style_set_current_feature("alerts");
if( zEmail==0 ){
style_header("Unsubscribe Fail");
@ <p>Unable to locate a subscriber with the requested key</p>
}else{
db_unprotect(PROTECT_READONLY);
if( bTotal ){
/* Completely delete the subscriber */
db_multi_exec(
"DELETE FROM subscriber WHERE subscriberId=%d", sid
);
}else{
/* Keep the subscriber, but turn off all notifications */
db_multi_exec(
"UPDATE subscriber SET ssub='k', mtime=now() WHERE subscriberId=%d",
sid
);
}
db_protect_pop();
style_header("Unsubscribed");
@ <p>The "%h(zEmail)" email address has been unsubscribed from all
@ notifications. All subscription records for "%h(zEmail)" have
@ been purged. No further emails will be sent to "%h(zEmail)".</p>
if( uid && g.perm.Admin ){
@ <p>You may also want to
@ <a href="%R/setup_uedit?id=%d(uid)">edit or delete
@ the corresponding user "%h(zLogin)"</a></p>
}
}
db_finalize(&q);
style_finish_page();
return;
}
/*
** WEBPAGE: alerts
**
** Edit email alert and notification settings.
**
** The subscriber is identified in several ways:
**
** * The name= query parameter contains the complete subscriberCode.
** This only happens when the user receives a verification
** email and clicks on the link in the email. When a
** compilete subscriberCode is seen on the name= query parameter,
** that constitutes verification of the email address.
**
** * The sid= query parameter contains an integer subscriberId.
** This only works for the administrator. It allows the
** administrator to edit any subscription.
**
** * The user is logged into an account other than "nobody" or
** "anonymous". In that case the notification settings
** associated with that account can be edited without needing
** to know the subscriber code.
**
** * The name= query parameter contains a 32-digit prefix of
** subscriber code. (Subscriber codes are normally 64 hex digits
** in length.) This uniquely identifies the subscriber without
** revealing the complete subscriber code, and hence without
** verifying the email address.
*/
void alert_page(void){
const char *zName = 0; /* Value of the name= query parameter */
Stmt q; /* For querying the database */
int sa, sc, sf, st, sw, sx; /* Types of notifications requested */
int sn, sr;
int sdigest = 0, sdonotcall = 0, sverified = 0; /* Other fields */
int isLogin; /* True if logged in as an individual */
const char *ssub = 0; /* Subscription flags */
const char *semail = 0; /* Email address */
const char *smip; /* */
const char *suname = 0; /* Corresponding user.login value */
const char *mtime; /* */
const char *sctime; /* Time subscription created */
int eErr = 0; /* Type of error */
char *zErr = 0; /* Error message text */
int sid = 0; /* Subscriber ID */
int nName; /* Length of zName in bytes */
char *zHalfCode; /* prefix of subscriberCode */
int keepAlive = 0; /* True to update the last contact time */
db_begin_transaction();
if( alert_webpages_disabled() ){
db_commit_transaction();
return;
}
login_check_credentials();
isLogin = login_is_individual();
zName = P("name");
nName = zName ? (int)strlen(zName) : 0;
if( g.perm.Admin && P("sid")!=0 ){
sid = atoi(P("sid"));
}
if( sid==0 && nName>=32 ){
sid = db_int(0,
"SELECT CASE WHEN hex(subscriberCode) LIKE (%Q||'%%')"
" THEN subscriberId ELSE 0 END"
" FROM subscriber WHERE subscriberCode>=hextoblob(%Q)"
" LIMIT 1", zName, zName);
if( sid ) keepAlive = 1;
}
if( sid==0 && isLogin && g.perm.EmailAlert ){
sid = db_int(0, "SELECT subscriberId FROM subscriber"
" WHERE suname=%Q", g.zLogin);
}
if( sid==0 ){
db_commit_transaction();
cgi_redirect("subscribe");
/*NOTREACHED*/
}
alert_submenu_common();
if( P("submit")!=0 && cgi_csrf_safe(2) ){
char newSsub[10];
int nsub = 0;
Blob update;
sdonotcall = PB("sdonotcall");
sdigest = PB("sdigest");
semail = P("semail");
if( PB("sa") ) newSsub[nsub++] = 'a';
if( g.perm.Read && PB("sc") ) newSsub[nsub++] = 'c';
if( g.perm.RdForum && PB("sf") ) newSsub[nsub++] = 'f';
if( g.perm.RdForum && PB("sn") ) newSsub[nsub++] = 'n';
if( g.perm.RdForum && PB("sr") ) newSsub[nsub++] = 'r';
if( g.perm.RdTkt && PB("st") ) newSsub[nsub++] = 't';
if( g.perm.RdWiki && PB("sw") ) newSsub[nsub++] = 'w';
if( g.perm.RdForum && PB("sx") ) newSsub[nsub++] = 'x';
newSsub[nsub] = 0;
ssub = newSsub;
blob_init(&update, "UPDATE subscriber SET", -1);
blob_append_sql(&update,
" sdonotcall=%d,"
" sdigest=%d,"
" ssub=%Q,"
" mtime=now(),"
" lastContact=now()/86400,"
" smip=%Q",
sdonotcall,
sdigest,
ssub,
g.zIpAddr
);
if( g.perm.Admin ){
suname = PT("suname");
sverified = PB("sverified");
if( suname && suname[0]==0 ) suname = 0;
blob_append_sql(&update,
", suname=%Q,"
" sverified=%d",
suname,
sverified
);
}
if( isLogin ){
if( semail==0 || email_address_is_valid(semail,0)==0 ){
eErr = 8;
}
blob_append_sql(&update, ", semail=%Q", semail);
}
blob_append_sql(&update," WHERE subscriberId=%d", sid);
if( eErr==0 ){
db_exec_sql(blob_str(&update));
ssub = 0;
}
blob_reset(&update);
}else if( keepAlive ){
db_unprotect(PROTECT_READONLY);
db_multi_exec(
"UPDATE subscriber SET lastContact=now()/86400"
" WHERE subscriberId=%d", sid
);
db_protect_pop();
}
if( P("delete")!=0 && cgi_csrf_safe(2) ){
if( !PB("dodelete") ){
eErr = 9;
zErr = mprintf("Select this checkbox and press \"Unsubscribe\" again to"
" unsubscribe");
}else{
alert_unsubscribe(sid, 1);
db_commit_transaction();
return;
}
}
style_set_current_feature("alerts");
style_header("Update Subscription");
db_prepare(&q,
"SELECT"
" semail," /* 0 */
" sverified," /* 1 */
" sdonotcall," /* 2 */
" sdigest," /* 3 */
" ssub," /* 4 */
" smip," /* 5 */
" suname," /* 6 */
" datetime(mtime,'unixepoch')," /* 7 */
" datetime(sctime,'unixepoch')," /* 8 */
" hex(subscriberCode)," /* 9 */
" date(coalesce(lastContact*86400,mtime),'unixepoch')," /* 10 */
" now()/86400 - coalesce(lastContact,mtime/86400)" /* 11 */
" FROM subscriber WHERE subscriberId=%d", sid);
if( db_step(&q)!=SQLITE_ROW ){
db_finalize(&q);
db_commit_transaction();
cgi_redirect("subscribe");
/*NOTREACHED*/
}
if( ssub==0 ){
semail = db_column_text(&q, 0);
sdonotcall = db_column_int(&q, 2);
sdigest = db_column_int(&q, 3);
ssub = db_column_text(&q, 4);
}
if( suname==0 ){
suname = db_column_text(&q, 6);
sverified = db_column_int(&q, 1);
}
sa = strchr(ssub,'a')!=0;
sc = strchr(ssub,'c')!=0;
sf = strchr(ssub,'f')!=0;
sn = strchr(ssub,'n')!=0;
sr = strchr(ssub,'r')!=0;
st = strchr(ssub,'t')!=0;
sw = strchr(ssub,'w')!=0;
sx = strchr(ssub,'x')!=0;
smip = db_column_text(&q, 5);
mtime = db_column_text(&q, 7);
sctime = db_column_text(&q, 8);
if( !g.perm.Admin && !sverified ){
if( nName==64 ){
db_unprotect(PROTECT_READONLY);
db_multi_exec(
"UPDATE subscriber SET sverified=1"
" WHERE subscriberCode=hextoblob(%Q)",
zName);
db_protect_pop();
if( db_get_boolean("selfreg-verify",0) ){
char *zNewCap = db_get("default-perms","u");
db_unprotect(PROTECT_USER);
db_multi_exec(
"UPDATE user"
" SET cap=%Q"
" WHERE cap='7' AND login=("
" SELECT suname FROM subscriber"
" WHERE subscriberCode=hextoblob(%Q))",
zNewCap, zName
);
db_protect_pop();
login_set_capabilities(zNewCap, 0);
}
@ <h1>Your email alert subscription has been verified!</h1>
@ <p>Use the form below to update your subscription information.</p>
@ <p>Hint: Bookmark this page so that you can more easily update
@ your subscription information in the future</p>
}else{
@ <h2>Your email address is unverified</h2>
@ <p>You should have received an email message containing a link
@ that you must visit to verify your account. No email notifications
@ will be sent until your email address has been verified.</p>
}
}else{
@ <p>Make changes to the email subscription shown below and
@ press "Submit".</p>
}
form_begin(0, "%R/alerts");
zHalfCode = db_text("x","SELECT hex(substr(subscriberCode,1,16))"
" FROM subscriber WHERE subscriberId=%d", sid);
@ <input type="hidden" name="name" value="%h(zHalfCode)">
@ <table class="subscribe">
@ <tr>
@ <td class="form_label">Email Address:</td>
if( isLogin ){
@ <td><input type="text" name="semail" value="%h(semail)" size="30">\
if( eErr==8 ){
@ <span class='loginError'>← not a valid email address!</span>
}else if( g.perm.Admin ){
@ <a href="%R/announce?to=%t(semail)">\
@ (Send a message to %h(semail))</a>\
}
@ </td>
}else{
@ <td>%h(semail)</td>
}
@ </tr>
if( g.perm.Admin ){
int uid;
@ <tr>
@ <td class='form_label'>Created:</td>
@ <td>%h(sctime)</td>
@ </tr>
@ <tr>
@ <td class='form_label'>Last Modified:</td>
@ <td>%h(mtime)</td>
@ </tr>
@ <tr>
@ <td class='form_label'>IP Address:</td>
@ <td>%h(smip)</td>
@ </tr>
@ <tr>
@ <td class='form_label'>Subscriber Code:</td>
@ <td>%h(db_column_text(&q,9))</td>
@ <tr>
@ <tr>
@ <td class='form_label'>Last Contact:</td>
@ <td>%h(db_column_text(&q,10)) ← \
@ %,d(db_column_int(&q,11)) days ago</td>
@ </tr>
@ <td class="form_label">User:</td>
@ <td><input type="text" name="suname" value="%h(suname?suname:"")" \
@ size="30">\
uid = db_int(0, "SELECT uid FROM user WHERE login=%Q", suname);
if( uid ){
@ <a href='%R/setup_uedit?id=%d(uid)'>\
@ (login info for %h(suname))</a>\
}
@ </tr>
}
@ <tr>
@ <td class="form_label">Topics:</td>
@ <td><label><input type="checkbox" name="sa" %s(sa?"checked":"")>\
@ Announcements</label><br>
if( g.perm.Read ){
@ <label><input type="checkbox" name="sc" %s(sc?"checked":"")>\
@ Check-ins</label><br>
}
if( g.perm.RdForum ){
@ <label><input type="checkbox" name="sf" %s(sf?"checked":"")>\
@ All Forum Posts</label><br>
@ <label><input type="checkbox" name="sn" %s(sn?"checked":"")>\
@ New Forum Threads</label><br>
@ <label><input type="checkbox" name="sr" %s(sr?"checked":"")>\
@ Replies To My Posts</label><br>
@ <label><input type="checkbox" name="sx" %s(sx?"checked":"")>\
@ Edits To Forum Posts</label><br>
}
if( g.perm.RdTkt ){
@ <label><input type="checkbox" name="st" %s(st?"checked":"")>\
@ Ticket changes</label><br>
}
if( g.perm.RdWiki ){
@ <label><input type="checkbox" name="sw" %s(sw?"checked":"")>\
@ Wiki</label>
}
@ </td></tr>
if( strchr(ssub,'k')!=0 ){
@ <tr><td></td><td> ↑
@ Note: User did a one-click unsubscribe</td></tr>
}
@ <tr>
@ <td class="form_label">Delivery:</td>
@ <td><select size="1" name="sdigest">
@ <option value="0" %s(sdigest?"":"selected")>Individual Emails</option>
@ <option value="1" %s(sdigest?"selected":"")>Daily Digest</option>
@ </select></td>
@ </tr>
if( g.perm.Admin ){
@ <tr>
@ <td class="form_label">Admin Options:</td><td>
@ <label><input type="checkbox" name="sdonotcall" \
@ %s(sdonotcall?"checked":"")> Do not disturb</label><br>
@ <label><input type="checkbox" name="sverified" \
@ %s(sverified?"checked":"")>\
@ Verified</label></td></tr>
}
if( eErr==9 ){
@ <tr>
@ <td class="form_label">Verify:</td><td>
@ <label><input type="checkbox" name="dodelete">
@ Unsubscribe</label>
@ <span class="loginError">← %h(zErr)</span>
@ </td></tr>
}
@ <tr>
@ <td></td>
@ <td><input type="submit" name="submit" value="Submit">
@ <input type="submit" name="delete" value="Unsubscribe">
@ </tr>
@ </table>
@ </form>
fossil_free(zErr);
db_finalize(&q);
style_finish_page();
db_commit_transaction();
return;
}
/*
** WEBPAGE: renew
**
** Users visit this page to update the last-contact date on their
** subscription. The last-contact date is the day that the subscriber
** last interacted with the repository. If the name= query parameter
** (or POST parameter) contains a valid subscriber code, then the last-contact
** subscription associated with that subscriber code is updated to be the
** current date.
*/
void renewal_page(void){
const char *zName = P("name");
int iInterval = db_get_int("email-renew-interval", 0);
Stmt s;
int rc;
style_header("Subscription Renewal");
if( zName==0 || strlen(zName)<4 ){
@ <p>No subscription specified</p>
style_finish_page();
return;
}
if( !db_table_has_column("repository","subscriber","lastContact")
|| iInterval<1
){
@ <p>This repository does not expire email notification subscriptions.
@ No renewals are necessary.</p>
style_finish_page();
return;
}
db_unprotect(PROTECT_READONLY);
db_prepare(&s,
"UPDATE subscriber"
" SET lastContact=now()/86400"
" WHERE subscriberCode=hextoblob(%Q)"
" RETURNING semail, date('now','+%d days');",
zName, iInterval+1
);
rc = db_step(&s);
if( rc==SQLITE_ROW ){
@ <p>The email notification subscription for %h(db_column_text(&s,0))
@ has been extended until %h(db_column_text(&s,1)) UTC.
}else{
@ <p>No such subscriber-id: %h(zName)</p>
}
db_finalize(&s);
db_protect_pop();
style_finish_page();
}
/* This is the message that gets sent to describe how to change
** or modify a subscription
*/
static const char zUnsubMsg[] =
@ To changes your subscription settings at %s visit this link:
@
@ %s/alerts/%s
@
@ To completely unsubscribe from %s, visit the following link:
@
@ %s/unsubscribe/%s
;
/*
** WEBPAGE: unsubscribe
** WEBPAGE: oneclickunsub
**
** Users visit this page to be delisted from email alerts.
**
** If a valid subscriber code is supplied in the name= query parameter,
** then that subscriber is delisted.
**
** Otherwise, If the users is logged in, then they are redirected
** to the /alerts page where they have an unsubscribe button.
**
** Non-logged-in users with no name= query parameter are invited to enter
** an email address to which will be sent the unsubscribe link that
** contains the correct subscriber code.
**
** The /unsubscribe page requires comfirmation. The /oneclickunsub
** page unsubscribes immediately without any need to confirm.
*/
void unsubscribe_page(void){
const char *zName = P("name");
char *zErr = 0;
int eErr = 0;
unsigned int uSeed = 0;
const char *zDecoded;
char *zCaptcha = 0;
int dx;
int bSubmit;
const char *zEAddr;
char *zCode = 0;
int sid = 0;
if( zName==0 ) zName = P("scode");
/* If a valid subscriber code is supplied, then either present the user
** with a confirmation, or if already confirmed, unsubscribe immediately.
*/
if( zName
&& (sid = db_int(0, "SELECT subscriberId FROM subscriber"
" WHERE subscriberCode=hextoblob(%Q)", zName))!=0
){
char *zUnsubName = mprintf("confirm%04x", sid);
if( P(zUnsubName)!=0 ){
alert_unsubscribe(sid, 1);
}else if( sqlite3_strglob("*oneclick*",g.zPath)==0 ){
alert_unsubscribe(sid, 0);
}else if( P("manage")!=0 ){
cgi_redirectf("%R/alerts/%s", zName);
}else{
style_header("Unsubscribe");
form_begin(0, "%R/unsubscribe");
@ <input type="hidden" name="scode" value="%h(zName)">
@ <table border="0" cellpadding="10" width="100%%">
@ <tr><td align="right">
@ <input type="submit" name="%h(zUnsubName)" value="Unsubscribe">
@ </td><td><big><b>←</b></big></td>
@ <td>Cancel your subscription to %h(g.zBaseURL) notifications
@ </td><tr>
@ <tr><td align="right">
@ <input type="submit" name="manage" \
@ value="Manage Subscription Settings">
@ </td><td><big><b>←</b></big></td>
@ <td>Make other changes to your subscription preferences
@ </td><tr>
@ </table>
@ </form>
style_finish_page();
}
return;
}
/* Logged in users are redirected to the /alerts page */
login_check_credentials();
if( login_is_individual() ){
cgi_redirectf("%R/alerts");
return;
}
style_set_current_feature("alerts");
zEAddr = PD("e","");
dx = atoi(PD("dx","0"));
bSubmit = P("submit")!=0 && P("e")!=0 && cgi_csrf_safe(2);
if( bSubmit ){
if( !captcha_is_correct(1) ){
eErr = 2;
zErr = mprintf("enter the security code shown below");
bSubmit = 0;
}
}
if( bSubmit ){
zCode = db_text(0,"SELECT hex(subscriberCode) FROM subscriber"
" WHERE semail=%Q", zEAddr);
if( zCode==0 ){
eErr = 1;
zErr = mprintf("not a valid email address");
bSubmit = 0;
}
}
if( bSubmit ){
/* If we get this far, it means that a valid unsubscribe request has
** been submitted. Send the appropriate email. */
Blob hdr, body;
AlertSender *pSender = alert_sender_new(0,0);
blob_init(&hdr,0,0);
blob_init(&body,0,0);
blob_appendf(&hdr, "To: <%s>\r\n", zEAddr);
blob_appendf(&hdr, "Subject: Unsubscribe Instructions\r\n");
blob_appendf(&body, zUnsubMsg/*works-like:"%s%s%s%s%s%s"*/,
g.zBaseURL, g.zBaseURL, zCode, g.zBaseURL, g.zBaseURL, zCode);
alert_send(pSender, &hdr, &body, 0);
style_header("Unsubscribe Instructions Sent");
if( pSender->zErr ){
@ <h1>Internal Error</h1>
@ <p>The following error was encountered while trying to send an
@ email to %h(zEAddr):
@ <blockquote><pre>
@ %h(pSender->zErr)
@ </pre></blockquote>
}else{
@ <p>An email has been sent to "%h(zEAddr)" that explains how to
@ unsubscribe and/or modify your subscription settings</p>
}
alert_sender_free(pSender);
style_finish_page();
return;
}
/* Non-logged-in users have to enter an email address to which is
** sent a message containing the unsubscribe link.
*/
style_header("Unsubscribe Request");
@ <p>Fill out the form below to request an email message that will
@ explain how to unsubscribe and/or change your subscription settings.</p>
@
form_begin(0, "%R/unsubscribe");
@ <table class="subscribe">
@ <tr>
@ <td class="form_label">Email Address:</td>
@ <td><input type="text" name="e" value="%h(zEAddr)" size="30"></td>
if( eErr==1 ){
@ <td><span class="loginError">← %h(zErr)</span></td>
}
@ </tr>
uSeed = captcha_seed();
zDecoded = captcha_decode(uSeed, 0);
zCaptcha = captcha_render(zDecoded);
@ <tr>
@ <td class="form_label">Security Code:</td>
@ <td><input type="text" name="captcha" value="" size="30">
captcha_speakit_button(uSeed, "Speak the code");
@ <input type="hidden" name="captchaseed" value="%u(uSeed)"></td>
if( eErr==2 ){
@ <td><span class="loginError">← %h(zErr)</span></td>
}
@ </tr>
@ <tr>
@ <td class="form_label">Options:</td>
@ <td><label><input type="radio" name="dx" value="0" %s(dx?"":"checked")>\
@ Modify subscription</label><br>
@ <label><input type="radio" name="dx" value="1" %s(dx?"checked":"")>\
@ Completely unsubscribe</label><br>
@ <tr>
@ <td></td>
@ <td><input type="submit" name="submit" value="Submit"></td>
@ </tr>
@ </table>
@ <div class="captcha"><table class="captcha"><tr><td><pre class="captcha">
@ %h(zCaptcha)
@ </pre>
@ Enter the 8 characters above in the "Security Code" box<br/>
@ </td></tr></table></div>
@ </form>
fossil_free(zErr);
style_finish_page();
}
/*
** WEBPAGE: subscribers
**
** This page, accessible to administrators only,
** shows a list of subscriber email addresses.
** Clicking on an email takes one to the /alerts page
** for that email where the delivery settings can be
** modified.
*/
void subscriber_list_page(void){
Blob sql;
Stmt q;
sqlite3_int64 iNow;
int nTotal;
int nPending;
int nDel = 0;
int iCutoff = db_get_int("email-renew-cutoff",0);
int iWarning = db_get_int("email-renew-warning",0);
char zCutoffClr[8];
char zWarnClr[8];
if( alert_webpages_disabled() ) return;
login_check_credentials();
if( !g.perm.Admin ){
login_needed(0);
return;
}
alert_submenu_common();
style_submenu_element("Users","setup_ulist");
style_set_current_feature("alerts");
style_header("Subscriber List");
nTotal = db_int(0, "SELECT count(*) FROM subscriber");
nPending = db_int(0, "SELECT count(*) FROM subscriber WHERE NOT sverified");
if( nPending>0 && P("purge") && cgi_csrf_safe(0) ){
int nNewPending;
db_multi_exec(
"DELETE FROM subscriber"
" WHERE NOT sverified AND mtime<now()-86400"
);
nNewPending = db_int(0, "SELECT count(*) FROM subscriber"
" WHERE NOT sverified");
nDel = nPending - nNewPending;
nPending = nNewPending;
nTotal -= nDel;
}
if( nPending>0 ){
@ <h1>%,d(nTotal) Subscribers, %,d(nPending) Pending</h1>
if( nDel==0 && 0<db_int(0,"SELECT count(*) FROM subscriber"
" WHERE NOT sverified AND mtime<now()-86400")
){
style_submenu_element("Purge Pending","subscribers?purge");
}
}else{
@ <h1>%,d(nTotal) Subscribers</h1>
}
if( nDel>0 ){
@ <p>*** %d(nDel) pending subscriptions deleted ***</p>
}
blob_init(&sql, 0, 0);
blob_append_sql(&sql,
"SELECT subscriberId," /* 0 */
" semail," /* 1 */
" ssub," /* 2 */
" suname," /* 3 */
" sverified," /* 4 */
" sdigest," /* 5 */
" mtime," /* 6 */
" date(sctime,'unixepoch')," /* 7 */
" (SELECT uid FROM user WHERE login=subscriber.suname)," /* 8 */
" coalesce(lastContact,mtime/86400)" /* 9 */
" FROM subscriber"
);
if( P("only")!=0 ){
blob_append_sql(&sql, " WHERE ssub LIKE '%%%q%%'", P("only"));
style_submenu_element("Show All","%R/subscribers");
}
blob_append_sql(&sql," ORDER BY mtime DESC");
db_prepare_blob(&q, &sql);
iNow = time(0);
memcpy(zCutoffClr, hash_color("A"), sizeof(zCutoffClr));
memcpy(zWarnClr, hash_color("HIJ"), sizeof(zWarnClr));
@ <table border='1' class='sortable' \
@ data-init-sort='6' data-column-types='tttttKKt'>
@ <thead>
@ <tr>
@ <th>Email
@ <th>Events
@ <th>Digest-Only?
@ <th>User
@ <th>Verified?
@ <th>Last change
@ <th>Last contact
@ <th>Created
@ </tr>
@ </thead><tbody>
while( db_step(&q)==SQLITE_ROW ){
sqlite3_int64 iMtime = db_column_int64(&q, 6);
double rAge = (iNow - iMtime)/86400.0;
int uid = db_column_int(&q, 8);
const char *zUname = db_column_text(&q, 3);
sqlite3_int64 iContact = db_column_int64(&q, 9);
double rContact = (iNow/86400.0) - iContact;
@ <tr>
@ <td><a href='%R/alerts?sid=%d(db_column_int(&q,0))'>\
@ %h(db_column_text(&q,1))</a></td>
@ <td>%h(db_column_text(&q,2))</td>
@ <td>%s(db_column_int(&q,5)?"digest":"")</td>
if( uid ){
@ <td><a href='%R/setup_uedit?id=%d(uid)'>%h(zUname)</a>
}else{
@ <td>%h(zUname)</td>
}
@ <td>%s(db_column_int(&q,4)?"yes":"pending")</td>
@ <td data-sortkey='%010llx(iMtime)'>%z(human_readable_age(rAge))</td>
@ <td data-sortkey='%010llx(iContact)'>\
if( iContact>iWarning ){
@ <span>\
}else if( iContact>iCutoff ){
@ <span style='background-color:%s(zWarnClr);'>\
}else{
@ <span style='background-color:%s(zCutoffClr);'>\
}
@ %z(human_readable_age(rContact))</td>
@ <td>%h(db_column_text(&q,7))</td>
@ </tr>
}
@ </tbody></table>
db_finalize(&q);
style_table_sorter();
style_finish_page();
}
#if LOCAL_INTERFACE
/*
** A single event that might appear in an alert is recorded as an
** instance of the following object.
**
** type values:
**
** c A new check-in
** f An original forum post
** n New forum threads
** r Replies to my forum posts
** x An edit to a prior forum post
** t A new ticket or a change to an existing ticket
** w A change to a wiki page
** x Edits to forum posts
*/
struct EmailEvent {
int type; /* 'c', 'f', 'n', 'r', 't', 'w', 'x' */
int needMod; /* Pending moderator approval */
Blob hdr; /* Header content, for forum entries */
Blob txt; /* Text description to appear in an alert */
char *zFromName; /* Human name of the sender */
char *zPriors; /* Upthread sender IDs for forum posts */
EmailEvent *pNext; /* Next in chronological order */
};
#endif
/*
** Free a linked list of EmailEvent objects
*/
void alert_free_eventlist(EmailEvent *p){
while( p ){
EmailEvent *pNext = p->pNext;
blob_reset(&p->txt);
blob_reset(&p->hdr);
fossil_free(p->zFromName);
fossil_free(p->zPriors);
fossil_free(p);
p = pNext;
}
}
/*
** Compute a string that is appropriate for the EmailEvent.zPriors field
** for a particular forum post.
**
** This string is an encode list of sender names and rids for all ancestors
** of the fpdi post - the post that fpid answer, the post that that parent
** post answers, and so forth back up to the root post. Duplicates sender
** names are omitted.
**
** The EmailEvent.zPriors field is used to screen events for people who
** only want to see replies to their own posts or to specific posts.
*/
static char *alert_compute_priors(int fpid){
return db_text(0,
"WITH priors(rid,who) AS ("
" SELECT firt, coalesce(euser,user)"
" FROM forumpost LEFT JOIN event ON fpid=objid"
" WHERE fpid=%d"
" UNION ALL"
" SELECT firt, coalesce(euser,user)"
" FROM priors, forumpost LEFT JOIN event ON fpid=objid"
" WHERE fpid=rid"
")"
"SELECT ','||group_concat(DISTINCT 'u'||who)||"
"','||group_concat(rid) FROM priors;",
fpid
);
}
/*
** Compute and return a linked list of EmailEvent objects
** corresponding to the current content of the temp.wantalert
** table which should be defined as follows:
**
** CREATE TEMP TABLE wantalert(eventId TEXT, needMod BOOLEAN);
*/
EmailEvent *alert_compute_event_text(int *pnEvent, int doDigest){
Stmt q;
EmailEvent *p;
EmailEvent anchor;
EmailEvent *pLast;
const char *zUrl = db_get("email-url","http://localhost:8080");
const char *zFrom;
const char *zSub;
/* First do non-forum post events */
db_prepare(&q,
"SELECT"
" CASE WHEN event.type='t'"
" THEN (SELECT substr(tagname,5) FROM tag"
" WHERE tagid=event.tagid AND tagname LIKE 'tkt-%%')"
" ELSE blob.uuid END," /* 0 */
" datetime(event.mtime)," /* 1 */
" coalesce(ecomment,comment)"
" || ' (user: ' || coalesce(euser,user,'?')"
" || (SELECT case when length(x)>0 then ' tags: ' || x else '' end"
" FROM (SELECT group_concat(substr(tagname,5), ', ') AS x"
" FROM tag, tagxref"
" WHERE tagname GLOB 'sym-*' AND tag.tagid=tagxref.tagid"
" AND tagxref.rid=blob.rid AND tagxref.tagtype>0))"
" || ')' as comment," /* 2 */
" wantalert.eventId," /* 3 */
" wantalert.needMod" /* 4 */
" FROM temp.wantalert, event, blob"
" WHERE blob.rid=event.objid"
" AND event.objid=substr(wantalert.eventId,2)+0"
" AND (%d OR eventId NOT GLOB 'f*')"
" ORDER BY event.mtime",
doDigest
);
memset(&anchor, 0, sizeof(anchor));
pLast = &anchor;
*pnEvent = 0;
while( db_step(&q)==SQLITE_ROW ){
const char *zType = "";
const char *zComment = db_column_text(&q, 2);
p = fossil_malloc_zero( sizeof(EmailEvent) );
pLast->pNext = p;
pLast = p;
p->type = db_column_text(&q, 3)[0];
p->needMod = db_column_int(&q, 4);
p->zFromName = 0;
p->pNext = 0;
switch( p->type ){
case 'c': zType = "Check-In"; break;
/* case 'f': -- forum posts omitted from this loop. See below */
case 't': zType = "Ticket Change"; break;
case 'w': {
zType = "Wiki Edit";
switch( zComment ? *zComment : 0 ){
case ':': ++zComment; break;
case '+': zType = "Wiki Added"; ++zComment; break;
case '-': zType = "Wiki Removed"; ++zComment; break;
}
break;
}
}
blob_init(&p->hdr, 0, 0);
blob_init(&p->txt, 0, 0);
blob_appendf(&p->txt,"== %s %s ==\n%s\n%s/info/%.20s\n",
db_column_text(&q,1),
zType,
zComment,
zUrl,
db_column_text(&q,0)
);
if( p->needMod ){
blob_appendf(&p->txt,
"** Pending moderator approval (%s/modreq) **\n",
zUrl
);
}
(*pnEvent)++;
}
db_finalize(&q);
/* Early-out if forumpost is not a table in this repository */
if( !db_table_exists("repository","forumpost") ){
return anchor.pNext;
}
/* For digests, the previous loop also handled forumposts already */
if( doDigest ){
return anchor.pNext;
}
/* If we reach this point, it means that forumposts exist and this
** is a normal email alert. Construct full-text forum post alerts
** using a format that enables them to be sent as separate emails.
*/
db_prepare(&q,
"SELECT"
" forumpost.fpid," /* 0: fpid */
" (SELECT uuid FROM blob WHERE rid=forumpost.fpid)," /* 1: hash */
" datetime(event.mtime)," /* 2: date/time */
" substr(comment,instr(comment,':')+2)," /* 3: comment */
" (WITH thread(fpid,fprev) AS ("
" SELECT fpid,fprev FROM forumpost AS tx"
" WHERE tx.froot=forumpost.froot),"
" basepid(fpid,bpid) AS ("
" SELECT fpid, fpid FROM thread WHERE fprev IS NULL"
" UNION ALL"
" SELECT thread.fpid, basepid.bpid FROM basepid, thread"
" WHERE basepid.fpid=thread.fprev)"
" SELECT uuid FROM blob, basepid"
" WHERE basepid.fpid=forumpost.firt"
" AND blob.rid=basepid.bpid)," /* 4: in-reply-to */
" wantalert.needMod," /* 5: moderated */
" coalesce(display_name(info),euser,user)," /* 6: user */
" forumpost.fprev IS NULL" /* 7: is an edit */
" FROM temp.wantalert, event, forumpost"
" LEFT JOIN user ON (login=coalesce(euser,user))"
" WHERE event.objid=substr(wantalert.eventId,2)+0"
" AND eventId GLOB 'f*'"
" AND forumpost.fpid=event.objid"
" ORDER BY event.mtime"
);
zFrom = db_get("email-self",0);
zSub = db_get("email-subname","");
while( db_step(&q)==SQLITE_ROW ){
int fpid = db_column_int(&q,0);
Manifest *pPost = manifest_get(fpid, CFTYPE_FORUM, 0);
const char *zIrt;
const char *zUuid;
const char *zTitle;
const char *z;
if( pPost==0 ) continue;
p = fossil_malloc( sizeof(EmailEvent) );
pLast->pNext = p;
pLast = p;
p->type = db_column_int(&q,7) ? 'f' : 'x';
p->needMod = db_column_int(&q, 5);
z = db_column_text(&q,6);
p->zFromName = z && z[0] ? fossil_strdup(z) : 0;
p->zPriors = alert_compute_priors(fpid);
p->pNext = 0;
blob_init(&p->hdr, 0, 0);
zUuid = db_column_text(&q, 1);
zTitle = db_column_text(&q, 3);
if( p->needMod ){
blob_appendf(&p->hdr, "Subject: %s Pending Moderation: %s\r\n",
zSub, zTitle);
}else{
blob_appendf(&p->hdr, "Subject: %s %s\r\n", zSub, zTitle);
blob_appendf(&p->hdr, "Message-Id: <%.32s@%s>\r\n",
zUuid, alert_hostname(zFrom));
zIrt = db_column_text(&q, 4);
if( zIrt && zIrt[0] ){
blob_appendf(&p->hdr, "In-Reply-To: <%.32s@%s>\r\n",
zIrt, alert_hostname(zFrom));
}
}
blob_init(&p->txt, 0, 0);
if( p->needMod ){
blob_appendf(&p->txt,
"** Pending moderator approval (%s/modreq) **\n",
zUrl
);
}
blob_appendf(&p->txt,
"Forum post by %s on %s\n",
pPost->zUser, db_column_text(&q, 2));
blob_appendf(&p->txt, "%s/forumpost/%S\n\n", zUrl, zUuid);
blob_append(&p->txt, pPost->zWiki, -1);
manifest_destroy(pPost);
(*pnEvent)++;
}
db_finalize(&q);
return anchor.pNext;
}
/*
** Put a header on an alert email
*/
void email_header(Blob *pOut){
blob_appendf(pOut,
"This is an automated email reporting changes "
"on Fossil repository %s (%s/timeline)\n",
db_get("email-subname","(unknown)"),
db_get("email-url","http://localhost:8080"));
}
/*
** COMMAND: test-alert
**
** Usage: %fossil test-alert EVENTID ...
**
** Generate the text of an email alert for all of the EVENTIDs
** listed on the command-line. Or if no events are listed on the
** command line, generate text for all events named in the
** pending_alert table. The text of the email alerts appears on
** standard output.
**
** This command is intended for testing and debugging Fossil itself,
** for example when enhancing the email alert system or fixing bugs
** in the email alert system. If you are not making changes to the
** Fossil source code, this command is probably not useful to you.
**
** EVENTIDs are text. The first character is 'c', 'f', 't', or 'w'
** for check-in, forum, ticket, or wiki. The remaining text is a
** integer that references the EVENT.OBJID value for the event.
** Run /timeline?showid to see these OBJID values.
**
** Options:
** --digest Generate digest alert text
** --needmod Assume all events are pending moderator approval
*/
void test_alert_cmd(void){
Blob out;
int nEvent;
int needMod;
int doDigest;
EmailEvent *pEvent, *p;
doDigest = find_option("digest",0,0)!=0;
needMod = find_option("needmod",0,0)!=0;
db_find_and_open_repository(0, 0);
verify_all_options();
db_begin_transaction();
alert_schema(0);
db_multi_exec("CREATE TEMP TABLE wantalert(eventid TEXT, needMod BOOLEAN)");
if( g.argc==2 ){
db_multi_exec(
"INSERT INTO wantalert(eventId,needMod)"
" SELECT eventid, %d FROM pending_alert", needMod);
}else{
int i;
for(i=2; i<g.argc; i++){
db_multi_exec("INSERT INTO wantalert(eventId,needMod) VALUES(%Q,%d)",
g.argv[i], needMod);
}
}
blob_init(&out, 0, 0);
email_header(&out);
pEvent = alert_compute_event_text(&nEvent, doDigest);
for(p=pEvent; p; p=p->pNext){
blob_append(&out, "\n", 1);
if( blob_size(&p->hdr) ){
blob_append(&out, blob_buffer(&p->hdr), blob_size(&p->hdr));
blob_append(&out, "\n", 1);
}
blob_append(&out, blob_buffer(&p->txt), blob_size(&p->txt));
}
alert_free_eventlist(pEvent);
fossil_print("%s", blob_str(&out));
blob_reset(&out);
db_end_transaction(0);
}
/*
** COMMAND: test-add-alerts
**
** Usage: %fossil test-add-alerts [OPTIONS] EVENTID ...
**
** Add one or more events to the pending_alert queue. Use this
** command during testing to force email notifications for specific
** events.
**
** EVENTIDs are text. The first character is 'c', 'f', 't', or 'w'
** for check-in, forum, ticket, or wiki. The remaining text is a
** integer that references the EVENT.OBJID value for the event.
** Run /timeline?showid to see these OBJID values.
**
** Options:
** --backoffice Run alert_backoffice() after all alerts have
** been added. This will cause the alerts to be
** sent out with the SENDALERT_TRACE option.
** --debug Like --backoffice, but add the SENDALERT_STDOUT
** so that emails are printed to standard output
** rather than being sent.
** --digest Process emails using SENDALERT_DIGEST
*/
void test_add_alert_cmd(void){
int i;
int doAuto = find_option("backoffice",0,0)!=0;
unsigned mFlags = 0;
if( find_option("debug",0,0)!=0 ){
doAuto = 1;
mFlags = SENDALERT_STDOUT;
}
if( find_option("digest",0,0)!=0 ){
mFlags |= SENDALERT_DIGEST;
}
db_find_and_open_repository(0, 0);
verify_all_options();
db_begin_write();
alert_schema(0);
for(i=2; i<g.argc; i++){
db_multi_exec("REPLACE INTO pending_alert(eventId) VALUES(%Q)", g.argv[i]);
}
db_end_transaction(0);
if( doAuto ){
alert_backoffice(SENDALERT_TRACE|mFlags);
}
}
/*
** Minimum number of days between renewal messages
*/
#define ALERT_RENEWAL_MSG_FREQUENCY 7 /* Do renewals at most once/week */
/*
** Construct the header and body for an email message that will alert
** a subscriber that their subscriptions are about to expire.
*/
static void alert_renewal_msg(
Blob *pHdr, /* Write email header here */
Blob *pBody, /* Write email body here */
const char *zCode, /* The subscriber code */
int lastContact, /* Last contact (days since 1970) */
const char *zEAddr, /* Subscriber email address. Send to this. */
const char *zSub, /* Subscription codes */
const char *zRepoName, /* Name of the sending Fossil repostory */
const char *zUrl /* URL for the sending Fossil repostory */
){
blob_appendf(pHdr,"To: <%s>\r\n", zEAddr);
blob_appendf(pHdr,"Subject: %s Subscription to %s expires soon\r\n",
zRepoName, zUrl);
blob_appendf(pBody,
"\nTo renew your subscription, click the following link:\n"
"\n %s/renew/%s\n\n",
zUrl, zCode
);
blob_appendf(pBody,
"You are currently receiving email notification for the following events\n"
"on the %s Fossil repository at %s:\n\n",
zRepoName, zUrl
);
if( strchr(zSub, 'a') ) blob_appendf(pBody, " * Announcements\n");
if( strchr(zSub, 'c') ) blob_appendf(pBody, " * Check-ins\n");
if( strchr(zSub, 'f') ) blob_appendf(pBody, " * Forum posts\n");
if( strchr(zSub, 't') ) blob_appendf(pBody, " * Ticket changes\n");
if( strchr(zSub, 'w') ) blob_appendf(pBody, " * Wiki changes\n");
blob_appendf(pBody, "\n"
"If you take no action, your subscription will expire and you will be\n"
"unsubscribed in about %d days. To make other changes or to unsubscribe\n"
"immediately, visit the following webpage:\n\n"
" %s/alerts/%s\n\n",
ALERT_RENEWAL_MSG_FREQUENCY, zUrl, zCode
);
}
/*
** If zUser is a sender of one of the ancestors of a forum post
** (if zUser appears in zPriors) then return true.
*/
static int alert_in_priors(const char *zUser, const char *zPriors){
int n = (int)strlen(zUser);
char zBuf[200];
if( n>195 ) return 0;
if( zPriors==0 || zPriors[0]==0 ) return 0;
zBuf[0] = ',';
zBuf[1] = 'u';
memcpy(zBuf+2, zUser, n+1);
return strstr(zPriors, zBuf)!=0;
}
#if INTERFACE
/*
** Flags for alert_send_alerts()
*/
#define SENDALERT_DIGEST 0x0001 /* Send a digest */
#define SENDALERT_PRESERVE 0x0002 /* Do not mark the task as done */
#define SENDALERT_STDOUT 0x0004 /* Print emails instead of sending */
#define SENDALERT_TRACE 0x0008 /* Trace operation for debugging */
#define SENDALERT_RENEWAL 0x0010 /* Send renewal notices */
#endif /* INTERFACE */
/*
** Send alert emails to subscribers.
**
** This procedure is run by either the backoffice, or in response to the
** "fossil alerts send" command. Details of operation are controlled by
** the flags parameter.
**
** Here is a summary of what happens:
**
** (1) Create a TEMP table wantalert(eventId,needMod) and fill it with
** all the events that we want to send alerts about. The needMod
** flags is set if and only if the event is still awaiting
** moderator approval. Events with the needMod flag are only
** shown to users that have moderator privileges.
**
** (2) Call alert_compute_event_text() to compute a list of EmailEvent
** objects that describe all events about which we want to send
** alerts.
**
** (3) Loop over all subscribers. Compose and send one or more email
** messages to each subscriber that describe the events for
** which the subscriber has expressed interest and has
** appropriate privileges.
**
** (4) Update the pending_alerts table to indicate that alerts have been
** sent.
**
** Update 2018-08-09: Do step (3) before step (4). Update the
** pending_alerts table *before* the emails are sent. That way, if
** the process malfunctions or crashes, some notifications may never
** be sent. But that is better than some recurring bug causing
** subscribers to be flooded with repeated notifications every 60
** seconds!
*/
int alert_send_alerts(u32 flags){
EmailEvent *pEvents, *p;
int nEvent = 0;
int nSent = 0;
Stmt q;
const char *zDigest = "false";
Blob hdr, body;
const char *zUrl;
const char *zRepoName;
const char *zFrom;
const char *zDest = (flags & SENDALERT_STDOUT) ? "stdout" : 0;
AlertSender *pSender = 0;
u32 senderFlags = 0;
int iInterval = 0; /* Subscription renewal interval */
if( g.fSqlTrace ) fossil_trace("-- BEGIN alert_send_alerts(%u)\n", flags);
alert_schema(0);
if( !alert_enabled() && (flags & SENDALERT_STDOUT)==0 ) goto send_alert_done;
zUrl = db_get("email-url",0);
if( zUrl==0 ) goto send_alert_done;
zRepoName = db_get("email-subname",0);
if( zRepoName==0 ) goto send_alert_done;
zFrom = db_get("email-self",0);
if( zFrom==0 ) goto send_alert_done;
if( flags & SENDALERT_TRACE ){
senderFlags |= ALERT_TRACE;
}
pSender = alert_sender_new(zDest, senderFlags);
/* Step (1): Compute the alerts that need sending
*/
db_multi_exec(
"DROP TABLE IF EXISTS temp.wantalert;"
"CREATE TEMP TABLE wantalert(eventId TEXT, needMod BOOLEAN, sentMod);"
);
if( flags & SENDALERT_DIGEST ){
/* Unmoderated changes are never sent as part of a digest */
db_multi_exec(
"INSERT INTO wantalert(eventId,needMod)"
" SELECT eventid, 0"
" FROM pending_alert"
" WHERE sentDigest IS FALSE"
" AND NOT EXISTS(SELECT 1 FROM private WHERE rid=substr(eventid,2));"
);
zDigest = "true";
}else{
/* Immediate alerts might include events that are subject to
** moderator approval */
db_multi_exec(
"INSERT INTO wantalert(eventId,needMod,sentMod)"
" SELECT eventid,"
" EXISTS(SELECT 1 FROM private WHERE rid=substr(eventid,2)),"
" sentMod"
" FROM pending_alert"
" WHERE sentSep IS FALSE;"
"DELETE FROM wantalert WHERE needMod AND sentMod;"
);
}
if( g.fSqlTrace ){
fossil_trace("-- wantalert contains %d rows\n",
db_int(0, "SELECT count(*) FROM wantalert")
);
}
/* Step 2: compute EmailEvent objects for every notification that
** needs sending.
*/
pEvents = alert_compute_event_text(&nEvent, (flags & SENDALERT_DIGEST)!=0);
if( nEvent==0 ) goto send_alert_expiration_warnings;
/* Step 4a: Update the pending_alerts table to designate the
** alerts as having all been sent. This is done *before* step (3)
** so that a crash will not cause alerts to be sent multiple times.
** Better a missed alert than being spammed with hundreds of alerts
** due to a bug.
*/
if( (flags & SENDALERT_PRESERVE)==0 ){
if( flags & SENDALERT_DIGEST ){
db_multi_exec(
"UPDATE pending_alert SET sentDigest=true"
" WHERE eventid IN (SELECT eventid FROM wantalert);"
);
}else{
db_multi_exec(
"UPDATE pending_alert SET sentSep=true"
" WHERE eventid IN (SELECT eventid FROM wantalert WHERE NOT needMod);"
"UPDATE pending_alert SET sentMod=true"
" WHERE eventid IN (SELECT eventid FROM wantalert WHERE needMod);"
);
}
}
/* Step 3: Loop over subscribers. Send alerts
*/
blob_init(&hdr, 0, 0);
blob_init(&body, 0, 0);
db_prepare(&q,
"SELECT"
" hex(subscriberCode)," /* 0 */
" semail," /* 1 */
" ssub," /* 2 */
" fullcap(user.cap)," /* 3 */
" suname" /* 4 */
" FROM subscriber LEFT JOIN user ON (login=suname)"
" WHERE sverified"
" AND NOT sdonotcall"
" AND sdigest IS %s"
" AND coalesce(subscriber.lastContact*86400,subscriber.mtime)>=%d",
zDigest/*safe-for-%s*/,
db_get_int("email-renew-cutoff",0)
);
while( db_step(&q)==SQLITE_ROW ){
const char *zCode = db_column_text(&q, 0);
const char *zSub = db_column_text(&q, 2);
const char *zEmail = db_column_text(&q, 1);
const char *zCap = db_column_text(&q, 3);
const char *zUser = db_column_text(&q, 4);
int nHit = 0;
for(p=pEvents; p; p=p->pNext){
if( strchr(zSub,p->type)==0 ){
if( p->type!='f' ) continue;
if( strchr(zSub,'n')!=0 && (p->zPriors==0 || p->zPriors[0]==0) ){
/* New post: accepted */
}else if( strchr(zSub,'r')!=0 && zUser!=0
&& alert_in_priors(zUser, p->zPriors) ){
/* A follow-up to a post written by the user: accept */
}else{
continue;
}
}
if( p->needMod ){
/* For events that require moderator approval, only send an alert
** if the recipient is a moderator for that type of event. Setup
** and Admin users always get notified. */
char xType = '*';
if( strpbrk(zCap,"as")==0 ){
switch( p->type ){
case 'x': case 'f':
case 'n': case 'r': xType = '5'; break;
case 't': xType = 'q'; break;
case 'w': xType = 'l'; break;
}
if( strchr(zCap,xType)==0 ) continue;
}
}else if( strchr(zCap,'s')!=0 || strchr(zCap,'a')!=0 ){
/* Setup and admin users can get any notification that does not
** require moderation */
}else{
/* Other users only see the alert if they have sufficient
** privilege to view the event itself */
char xType = '*';
switch( p->type ){
case 'c': xType = 'o'; break;
case 'x': case 'f':
case 'n': case 'r': xType = '2'; break;
case 't': xType = 'r'; break;
case 'w': xType = 'j'; break;
}
if( strchr(zCap,xType)==0 ) continue;
}
if( blob_size(&p->hdr)>0 ){
/* This alert should be sent as a separate email */
Blob fhdr, fbody;
blob_init(&fhdr, 0, 0);
blob_appendf(&fhdr, "To: <%s>\r\n", zEmail);
blob_append(&fhdr, blob_buffer(&p->hdr), blob_size(&p->hdr));
blob_init(&fbody, blob_buffer(&p->txt), blob_size(&p->txt));
blob_appendf(&fhdr, "List-Unsubscribe: <%s/oneclickunsub/%s>\r\n",
zUrl, zCode);
blob_appendf(&fhdr,
"List-Unsubscribe-Post: List-Unsubscribe=One-Click\r\n");
blob_appendf(&fbody, "\n-- \nUnsubscribe: %s/unsubscribe/%s\n",
zUrl, zCode);
/* blob_appendf(&fbody, "Subscription settings: %s/alerts/%s\n",
** zUrl, zCode); */
alert_send(pSender,&fhdr,&fbody,p->zFromName);
nSent++;
blob_reset(&fhdr);
blob_reset(&fbody);
}else{
/* Events other than forum posts are gathered together into
** a single email message */
if( nHit==0 ){
blob_appendf(&hdr,"To: <%s>\r\n", zEmail);
blob_appendf(&hdr,"Subject: %s activity alert\r\n", zRepoName);
blob_appendf(&body,
"This is an automated email sent by the Fossil repository "
"at %s to report changes.\n",
zUrl
);
}
nHit++;
blob_append(&body, "\n", 1);
blob_append(&body, blob_buffer(&p->txt), blob_size(&p->txt));
}
}
if( nHit==0 ) continue;
blob_appendf(&hdr, "List-Unsubscribe: <%s/oneclickunsub/%s>\r\n",
zUrl, zCode);
blob_appendf(&hdr, "List-Unsubscribe-Post: List-Unsubscribe=One-Click\r\n");
blob_appendf(&body,"\n-- \nSubscription info: %s/alerts/%s\n",
zUrl, zCode);
alert_send(pSender,&hdr,&body,0);
nSent++;
blob_truncate(&hdr, 0);
blob_truncate(&body, 0);
}
blob_reset(&hdr);
blob_reset(&body);
db_finalize(&q);
alert_free_eventlist(pEvents);
/* Step 4b: Update the pending_alerts table to remove all of the
** alerts that have been completely sent.
*/
db_multi_exec("DELETE FROM pending_alert WHERE sentDigest AND sentSep;");
/* Send renewal messages to subscribers whose subscriptions are about
** to expire. Only do this if:
**
** (1) email-renew-interval is 14 or greater (or in other words if
** subscription expiration is enabled).
**
** (2) The SENDALERT_RENEWAL flag is set
*/
send_alert_expiration_warnings:
if( (flags & SENDALERT_RENEWAL)!=0
&& (iInterval = db_get_int("email-renew-interval",0))>=14
){
int iNow = (int)(time(0)/86400);
int iOldWarn = db_get_int("email-renew-warning",0);
int iNewWarn = iNow - iInterval + ALERT_RENEWAL_MSG_FREQUENCY;
if( iNewWarn >= iOldWarn + ALERT_RENEWAL_MSG_FREQUENCY ){
db_prepare(&q,
"SELECT"
" hex(subscriberCode)," /* 0 */
" lastContact," /* 1 */
" semail," /* 2 */
" ssub" /* 3 */
" FROM subscriber"
" WHERE lastContact<=%d AND lastContact>%d"
" AND NOT sdonotcall"
" AND length(sdigest)>0",
iNewWarn, iOldWarn
);
while( db_step(&q)==SQLITE_ROW ){
Blob hdr, body;
blob_init(&hdr, 0, 0);
blob_init(&body, 0, 0);
alert_renewal_msg(&hdr, &body,
db_column_text(&q,0),
db_column_int(&q,1),
db_column_text(&q,2),
db_column_text(&q,3),
zRepoName, zUrl);
alert_send(pSender,&hdr,&body,0);
blob_reset(&hdr);
blob_reset(&body);
}
db_finalize(&q);
if( (flags & SENDALERT_PRESERVE)==0 ){
if( iOldWarn>0 ){
db_set_int("email-renew-cutoff", iOldWarn, 0);
}
db_set_int("email-renew-warning", iNewWarn, 0);
}
}
}
send_alert_done:
alert_sender_free(pSender);
if( g.fSqlTrace ) fossil_trace("-- END alert_send_alerts(%u)\n", flags);
return nSent;
}
/*
** Do backoffice processing for email notifications. In other words,
** check to see if any email notifications need to occur, and then
** do them.
**
** This routine is intended to run in the background, after webpages.
**
** The mFlags option is zero or more of the SENDALERT_* flags. Normally
** this flag is zero, but the test-set-alert command sets it to
** SENDALERT_TRACE.
*/
int alert_backoffice(u32 mFlags){
int iJulianDay;
int nSent = 0;
if( !alert_tables_exist() ) return 0;
nSent = alert_send_alerts(mFlags);
iJulianDay = db_int(0, "SELECT julianday('now')");
if( iJulianDay>db_get_int("email-last-digest",0) ){
db_set_int("email-last-digest",iJulianDay,0);
nSent += alert_send_alerts(SENDALERT_DIGEST|SENDALERT_RENEWAL|mFlags);
}
return nSent;
}
/*
** WEBPAGE: contact_admin
**
** A web-form to send an email message to the repository administrator,
** or (with appropriate permissions) to anybody.
*/
void contact_admin_page(void){
const char *zAdminEmail = db_get("email-admin",0);
unsigned int uSeed = 0;
const char *zDecoded;
char *zCaptcha = 0;
login_check_credentials();
style_set_current_feature("alerts");
if( zAdminEmail==0 || zAdminEmail[0]==0 ){
style_header("Outbound Email Disabled");
@ <p>Outbound email is disabled on this repository
style_finish_page();
return;
}
if( P("submit")!=0
&& P("subject")!=0
&& P("msg")!=0
&& P("from")!=0
&& cgi_csrf_safe(2)
&& captcha_is_correct(0)
){
Blob hdr, body;
AlertSender *pSender = alert_sender_new(0,0);
blob_init(&hdr, 0, 0);
blob_appendf(&hdr, "To: <%s>\r\nSubject: %s administrator message\r\n",
zAdminEmail, db_get("email-subname","Fossil Repo"));
blob_init(&body, 0, 0);
blob_appendf(&body, "Message from [%s]\n", PT("from")/*safe-for-%s*/);
blob_appendf(&body, "Subject: [%s]\n\n", PT("subject")/*safe-for-%s*/);
blob_appendf(&body, "%s", PT("msg")/*safe-for-%s*/);
alert_send(pSender, &hdr, &body, 0);
style_header("Message Sent");
if( pSender->zErr ){
@ <h1>Internal Error</h1>
@ <p>The following error was reported by the system:
@ <blockquote><pre>
@ %h(pSender->zErr)
@ </pre></blockquote>
}else{
@ <p>Your message has been sent to the repository administrator.
@ Thank you for your input.</p>
}
alert_sender_free(pSender);
style_finish_page();
return;
}
if( captcha_needed() ){
uSeed = captcha_seed();
zDecoded = captcha_decode(uSeed, 0);
zCaptcha = captcha_render(zDecoded);
}
style_set_current_feature("alerts");
style_header("Message To Administrator");
form_begin(0, "%R/contact_admin");
@ <p>Enter a message to the repository administrator below:</p>
@ <table class="subscribe">
if( zCaptcha ){
@ <tr>
@ <td class="form_label">Security Code:</td>
@ <td><input type="text" name="captcha" value="" size="10">
captcha_speakit_button(uSeed, "Speak the code");
@ <input type="hidden" name="captchaseed" value="%u(uSeed)"></td>
@ </tr>
}
@ <tr>
@ <td class="form_label">Your Email Address:</td>
@ <td><input type="text" name="from" value="%h(PT("from"))" size="30"></td>
@ </tr>
@ <tr>
@ <td class="form_label">Subject:</td>
@ <td><input type="text" name="subject" value="%h(PT("subject"))"\
@ size="80"></td>
@ </tr>
@ <tr>
@ <td class="form_label">Message:</td>
@ <td><textarea name="msg" cols="80" rows="10" wrap="virtual">\
@ %h(PT("msg"))</textarea>
@ </tr>
@ <tr>
@ <td></td>
@ <td><input type="submit" name="submit" value="Send Message">
@ </tr>
@ </table>
if( zCaptcha ){
@ <div class="captcha"><table class="captcha"><tr><td><pre class="captcha">
@ %h(zCaptcha)
@ </pre>
@ Enter the 8 characters above in the "Security Code" box<br/>
@ </td></tr></table></div>
}
@ </form>
style_finish_page();
}
/*
** Send an annoucement message described by query parameter.
** Permission to do this has already been verified.
*/
static char *alert_send_announcement(void){
AlertSender *pSender;
char *zErr;
const char *zTo = PT("to");
char *zSubject = PT("subject");
int bAll = PB("all");
int bAA = PB("aa");
int bMods = PB("mods");
const char *zSub = db_get("email-subname", "[Fossil Repo]");
int bTest2 = fossil_strcmp(P("name"),"test2")==0;
Blob hdr, body;
blob_init(&body, 0, 0);
blob_init(&hdr, 0, 0);
blob_appendf(&body, "%s", PT("msg")/*safe-for-%s*/);
pSender = alert_sender_new(bTest2 ? "blob" : 0, 0);
if( zTo[0] ){
blob_appendf(&hdr, "To: <%s>\r\nSubject: %s %s\r\n", zTo, zSub, zSubject);
alert_send(pSender, &hdr, &body, 0);
}
if( bAll || bAA || bMods ){
Stmt q;
int nUsed = blob_size(&body);
const char *zURL = db_get("email-url",0);
if( bAll ){
db_prepare(&q, "SELECT semail, hex(subscriberCode) FROM subscriber "
" WHERE sverified AND NOT sdonotcall");
}else if( bAA ){
db_prepare(&q, "SELECT semail, hex(subscriberCode) FROM subscriber "
" WHERE sverified AND NOT sdonotcall"
" AND ssub LIKE '%%a%%'");
}else if( bMods ){
db_prepare(&q,
"SELECT semail, hex(subscriberCode)"
" FROM subscriber, user "
" WHERE sverified AND NOT sdonotcall"
" AND suname=login"
" AND fullcap(cap) GLOB '*5*'");
}
while( db_step(&q)==SQLITE_ROW ){
const char *zCode = db_column_text(&q, 1);
zTo = db_column_text(&q, 0);
blob_truncate(&hdr, 0);
blob_appendf(&hdr, "To: <%s>\r\nSubject: %s %s\r\n", zTo, zSub, zSubject);
if( zURL ){
blob_truncate(&body, nUsed);
blob_appendf(&body,"\n-- \nSubscription info: %s/alerts/%s\n",
zURL, zCode);
}
alert_send(pSender, &hdr, &body, 0);
}
db_finalize(&q);
}
if( bTest2 ){
/* If the URL is /announce/test2 instead of just /announce, then no
** email is actually sent. Instead, the text of the email that would
** have been sent is displayed in the result window. */
@ <pre style='border: 2px solid blue; padding: 1ex'>
@ %h(blob_str(&pSender->out))
@ </pre>
}
zErr = pSender->zErr;
pSender->zErr = 0;
alert_sender_free(pSender);
return zErr;
}
/*
** WEBPAGE: announce
**
** A web-form, available to users with the "Send-Announcement" or "A"
** capability, that allows one to send announcements to whomever
** has subscribed to receive announcements. The administrator can
** also send a message to an arbitrary email address and/or to all
** subscribers regardless of whether or not they have elected to
** receive announcements.
*/
void announce_page(void){
const char *zAction = "announce"
/* Maintenance reminder: we need an explicit action=THIS_PAGE on the
** form element to avoid that a URL arg of to=... passed to this
** page ends up overwriting the form-posted "to" value. This
** action value differs for the test1 request path.
*/;
login_check_credentials();
if( !g.perm.Announce ){
login_needed(0);
return;
}
style_set_current_feature("alerts");
if( fossil_strcmp(P("name"),"test1")==0 ){
/* Visit the /announce/test1 page to see the CGI variables */
zAction = "announce/test1";
@ <p style='border: 1px solid black; padding: 1ex;'>
cgi_print_all(0, 0, 0);
@ </p>
}else if( P("submit")!=0 && cgi_csrf_safe(2) ){
char *zErr = alert_send_announcement();
style_header("Announcement Sent");
if( zErr ){
@ <h1>Internal Error</h1>
@ <p>The following error was reported by the system:
@ <blockquote><pre>
@ %h(zErr)
@ </pre></blockquote>
}else{
@ <p>The announcement has been sent.
@ <a href="%h(PD("REQUEST_URI","/"))">Send another</a></p>
}
style_finish_page();
return;
} else if( !alert_enabled() ){
style_header("Cannot Send Announcement");
@ <p>Either you have no subscribers yet, or email alerts are not yet
@ <a href="https://fossil-scm.org/fossil/doc/trunk/www/alerts.md">set up</a>
@ for this repository.</p>
return;
}
style_header("Send Announcement");
@ <form method="POST" action="%R/%s(zAction)">
login_insert_csrf_secret();
@ <table class="subscribe">
if( g.perm.Admin ){
int aa = PB("aa");
int all = PB("all");
int aMod = PB("mods");
const char *aack = aa ? "checked" : "";
const char *allck = all ? "checked" : "";
const char *modck = aMod ? "checked" : "";
@ <tr>
@ <td class="form_label">To:</td>
@ <td><input type="text" name="to" value="%h(PT("to"))" size="30"><br>
@ <label><input type="checkbox" name="aa" %s(aack)> \
@ All "announcement" subscribers</label> \
@ <a href="%R/subscribers?only=a" target="_blank">(list)</a><br>
@ <label><input type="checkbox" name="all" %s(allck)> \
@ All subscribers</label> \
@ <a href="%R/subscribers" target="_blank">(list)</a><br>
@ <label><input type="checkbox" name="mods" %s(modck)> \
@ All moderators</label> \
@ <a href="%R/setup_ulist?with=5" target="_blank">(list)</a><br></td>
@ </tr>
}
@ <tr>
@ <td class="form_label">Subject:</td>
@ <td><input type="text" name="subject" value="%h(PT("subject"))"\
@ size="80"></td>
@ </tr>
@ <tr>
@ <td class="form_label">Message:</td>
@ <td><textarea name="msg" cols="80" rows="10" wrap="virtual">\
@ %h(PT("msg"))</textarea>
@ </tr>
@ <tr>
@ <td></td>
if( fossil_strcmp(P("name"),"test2")==0 ){
@ <td><input type="submit" name="submit" value="Dry Run">
}else{
@ <td><input type="submit" name="submit" value="Send Message">
}
@ </tr>
@ </table>
@ </form>
style_finish_page();
}