diff --git a/.gitignore b/.gitignore index 710ec373..14c576b6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,15 @@ +/.idea +/last-failed-e2e-test.* + node_modules npm-debug.log .DS_Store config/development.* config/production.* +config/test.* workers/reports/config/development.* workers/reports/config/production.* +workers/reports/config/test.* dump.rdb # generate POT file every time you want to update your PO file diff --git a/Gruntfile.js b/Gruntfile.js index 6ac983cc..daf89982 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -9,7 +9,7 @@ module.exports = function (grunt) { }, nodeunit: { - all: ['test/**/*-test.js'] + all: ['test/nodeunit/**/*-test.js'] }, jsxgettext: { diff --git a/README.md b/README.md index 721ffe42..87fd2aea 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,11 @@ # Mailtrain -[Mailtrain](http://mailtrain.org) is a self hosted newsletter application built on Node.js (v5+) and MySQL (v5.5+ or MariaDB). +[Mailtrain](http://mailtrain.org) is a self hosted newsletter application built on Node.js (v7+) and MySQL (v5.5+ or MariaDB). ![](http://mailtrain.org/mailtrain.png) +> Mailtrain requires at least **Node.js v7**. If you want to use an older version of Node.js then you should use version v1.24 of Mailtrain. You can either download it [here](https://github.com/Mailtrain-org/mailtrain/archive/v1.24.0.zip) or if using git then run `git checkout v1.24.0` before starting it + ## Features Mailtrain supports subscriber list management, list segmentation, custom fields, email templates, large CSV list import files, etc. @@ -45,7 +47,7 @@ Check out [ZoneMTA](https://github.com/zone-eu/zone-mta) as an alternative self ## Requirements - * Nodejs v6+ + * Nodejs v7+ * MySQL v5.5 or MariaDB * Redis. Optional, disabled by default. Used for session storage and for caching state between multiple processes. If you do not have Redis enabled then you can only use a single sender process diff --git a/app.js b/app.js index bbceeceb..27d5bd81 100644 --- a/app.js +++ b/app.js @@ -184,6 +184,12 @@ app.use((req, res, next) => { res.locals.customStyles = config.customstyles || []; res.locals.customScripts = config.customscripts || []; + let bodyClasses = []; + if (req.user) { + bodyClasses.push('logged-in user-' + req.user.username); + } + res.locals.bodyClass = bodyClasses.join(' '); + settingsModel.list(['ua_code', 'shoutout'], (err, configItems) => { if (err) { return next(err); diff --git a/config/default.toml b/config/default.toml index 19067c6f..8bead74e 100644 --- a/config/default.toml +++ b/config/default.toml @@ -110,16 +110,6 @@ host="0.0.0.0" # VERP hostname is in the same domain as the From address. # disablesenderheader=true -[testserver] -# Starts a vanity server that redirects all mail to /dev/null -# Mostly needed for local development -enabled=false -port=5587 -host="0.0.0.0" -username="testuser" -password="testpass" -logger=false - [ldap] # enable to use ldap user backend enabled=false @@ -178,17 +168,30 @@ templates=[["demo", "Demo Template"]] # then it's safer to switch off the reporting functionality below. enabled=false +[testserver] +# Starts a vanity server that redirects all mail to /dev/null +# Mostly needed for local development +enabled=false +port=5587 +mailboxserverport=3001 +host="0.0.0.0" +username="testuser" +password="testpass" +logger=false + +[seleniumwebdriver] +browser="phantomjs" + [shares.list.master] name="Master" description="All permissions" permissions=["view"] - [shares.namespace.master] name="Master" description="All permissions" [shares.namespace.master.permissions] list=["view"] -namespace=["view", "edit", "create", "delete", "create list"] \ No newline at end of file +namespace=["view", "edit", "create", "delete", "create list"] diff --git a/lib/dbcheck.js b/lib/dbcheck.js index 001723a1..93264800 100644 --- a/lib/dbcheck.js +++ b/lib/dbcheck.js @@ -107,13 +107,14 @@ function getSql(path, data, callback) { if (err) { return callback(err); } - let renderer = Handlebars.compile(source); - return callback(null, renderer(data || {})); + const rendered = data ? Handlebars.compile(source)(data) : source; + return callback(null, rendered); }); } function runInitial(callback) { - let fname = process.env.DB_FROM_START ? 'base.sql' : 'mailtrain.sql'; + let dump = process.env.NODE_ENV === 'test' ? 'mailtrain-test.sql' : 'mailtrain.sql'; + let fname = process.env.DB_FROM_START ? 'base.sql' : dump; let path = pathlib.join(__dirname, '..', 'setup', 'sql', fname); log.info('sql', 'Loading tables from %s', fname); applyUpdate({ diff --git a/lib/tools.js b/lib/tools.js index 901ad0df..2e7a32e2 100644 --- a/lib/tools.js +++ b/lib/tools.js @@ -148,7 +148,6 @@ function updateMenu(res) { } function validateEmail(address, checkBlocked, callback) { - let user = (address || '').toString().split('@').shift().toLowerCase().replace(/[^a-z0-9]/g, ''); if (checkBlocked && blockedUsers.indexOf(user) >= 0) { return callback(new Error(util.format(_('Blocked email address "%s"'), address))); diff --git a/package.json b/package.json index 20d6140a..1eb9b921 100644 --- a/package.json +++ b/package.json @@ -8,12 +8,17 @@ "test": "grunt", "start": "node index.js", "sqlinit": "node setup/sql/init.js", - "sqldump": "node setup/sql/dump.js | sed -e '/^\\/\\*.*\\*\\/;$/d' -e 's/.[0-9]\\{4\\}-[0-9][0-9]-[0-9][0-9] [0-9][0-9]:[0-9][0-9]:[0-9][0-9]./NOW()/g' > setup/sql/mailtrain.sql", + "sqldump": "node setup/sql/dump.js | sed -e '/^\\/\\*.*\\*\\/;$/d' -e 's/.[0-9]\\{4\\}-[0-9][0-9]-[0-9][0-9] [0-9][0-9]:[0-9][0-9]:[0-9][0-9]./NOW()/g' > setup/sql/mailtrain${DUMP_NAME_SUFFIX}.sql", "sqldrop": "node setup/sql/drop.js", "sqlgen": "npm run sqldrop && DB_FROM_START=Y npm run sqlinit && npm run sqldump", "langs:hbs": "jsxgettext -L handlebars -k translate -o langs/hbs.pot views/layout.hbs views/index.hbs", "langs:js": "jsxgettext -o languages/js.pot routes/index.js", - "langs": "npm run langs:hbs && npm run langs:js" + "langs": "npm run langs:hbs && npm run langs:js", + "sqldumptest": "NODE_ENV=test DUMP_NAME_SUFFIX=-test npm run sqldump", + "sqlresettest": "NODE_ENV=test npm run sqldrop && NODE_ENV=test npm run sqlinit", + "starttest": "NODE_ENV=test node index.js", + "_e2e": "NODE_ENV=test node test/e2e/index.js", + "e2e": "npm run sqlresettest && npm run _e2e" }, "repository": { "type": "git", @@ -26,12 +31,18 @@ "node": ">=5.0.0" }, "devDependencies": { + "babel-eslint": "^7.2.3", + "chai": "^3.5.0", "eslint-config-nodemailer": "^1.0.0", "grunt": "^1.0.1", "grunt-cli": "^1.2.0", "grunt-contrib-nodeunit": "^1.0.0", "grunt-eslint": "^19.0.0", - "jsxgettext-andris": "^0.9.0-patch.1" + "jsxgettext-andris": "^0.9.0-patch.1", + "mocha": "^3.3.0", + "phantomjs": "^2.1.7", + "selenium-webdriver": "^3.4.0", + "url-pattern": "^1.0.3" }, "optionalDependencies": { "posix": "^4.1.1" @@ -59,6 +70,7 @@ "faker": "^4.1.0", "feedparser": "^2.1.0", "file-type": "^4.1.0", + "fs-extra": "^3.0.1", "geoip-ultralight": "^0.1.5", "gettext-parser": "^1.2.2", "gm": "^1.23.0", @@ -74,6 +86,7 @@ "juice": "^4.0.2", "knex": "^0.13.0", "libmime": "^3.1.0", + "mailparser": "^2.0.5", "marked": "^0.3.6", "memory-cache": "^0.1.6", "mjml": "3.3.0", diff --git a/routes/subscription.js b/routes/subscription.js index 235a6a3b..6310bbf3 100644 --- a/routes/subscription.js +++ b/routes/subscription.js @@ -702,7 +702,12 @@ router.get('/:lcid/unsubscribe/:ucid', passport.csrfProtection, (req, res, next) } - if (req.query.formTest || + const autoUnsubscribe = req.query.auto === 'yes'; + + if (autoUnsubscribe) { + handleUnsubscribe(list, subscription, autoUnsubscribe, req.query.c, req.ip, res, next); + + } else if (req.query.formTest || list.unsubscriptionMode === lists.UnsubscriptionMode.ONE_STEP_WITH_FORM || list.unsubscriptionMode === lists.UnsubscriptionMode.TWO_STEP_WITH_FORM) { @@ -741,7 +746,7 @@ router.get('/:lcid/unsubscribe/:ucid', passport.csrfProtection, (req, res, next) }); }); } else { // UnsubscriptionMode.ONE_STEP || UnsubscriptionMode.TWO_STEP || UnsubscriptionMode.MANUAL - handleUnsubscribe(list, subscription, req.query.c, req.ip, res, next); + handleUnsubscribe(list, subscription, autoUnsubscribe, req.query.c, req.ip, res, next); } }); }); @@ -771,14 +776,32 @@ router.post('/:lcid/unsubscribe', passport.parseForm, passport.csrfProtection, ( return next(err); } - handleUnsubscribe(list, subscription, campaignId, req.ip, res, next); + handleUnsubscribe(list, subscription, false, campaignId, req.ip, res, next); }); }); }); -function handleUnsubscribe(list, subscription, campaignId, ip, res, next) { - if (list.unsubscriptionMode === lists.UnsubscriptionMode.TWO_STEP || - list.unsubscriptionMode === lists.UnsubscriptionMode.TWO_STEP_WITH_FORM) { +function handleUnsubscribe(list, subscription, autoUnsubscribe, campaignId, ip, res, next) { + if ((list.unsubscriptionMode === lists.UnsubscriptionMode.ONE_STEP || list.unsubscriptionMode === lists.UnsubscriptionMode.ONE_STEP_WITH_FORM) || + (autoUnsubscribe && (list.unsubscriptionMode === lists.UnsubscriptionMode.TWO_STEP || list.unsubscriptionMode === lists.UnsubscriptionMode.TWO_STEP_WITH_FORM)) ) { + + subscriptions.changeStatus(list.id, subscription.id, campaignId, subscriptions.Status.UNSUBSCRIBED, (err, found) => { + if (err) { + return next(err); + } + + // TODO: Shall we do anything with "found"? + + mailHelpers.sendUnsubscriptionConfirmed(list, subscription.email, subscription, err => { + if (err) { + return next(err); + } + + res.redirect('/subscription/' + list.cid + '/unsubscribed-notice'); + }); + }); + + } else if (list.unsubscriptionMode === lists.UnsubscriptionMode.TWO_STEP || list.unsubscriptionMode === lists.UnsubscriptionMode.TWO_STEP_WITH_FORM) { const data = { subscriptionId: subscription.id, @@ -799,24 +822,6 @@ function handleUnsubscribe(list, subscription, campaignId, ip, res, next) { }); }); - } else if (list.unsubscriptionMode === lists.UnsubscriptionMode.ONE_STEP || - list.unsubscriptionMode === lists.UnsubscriptionMode.ONE_STEP_WITH_FORM) { - - subscriptions.changeStatus(list.id, subscription.id, campaignId, subscriptions.Status.UNSUBSCRIBED, (err, found) => { - if (err) { - return next(err); - } - - // TODO: Shall we do anything with "found"? - - mailHelpers.sendUnsubscriptionConfirmed(list, subscription.email, subscription, err => { - if (err) { - return next(err); - } - - res.redirect('/subscription/' + list.cid + '/unsubscribed-notice'); - }); - }); } else { // UnsubscriptionMode.MANUAL res.redirect('/subscription/' + list.cid + '/manual-unsubscribe-notice'); } diff --git a/services/test-server.js b/services/test-server.js index 025a16df..1f4777b8 100644 --- a/services/test-server.js +++ b/services/test-server.js @@ -4,12 +4,37 @@ let log = require('npmlog'); let config = require('config'); let crypto = require('crypto'); let humanize = require('humanize'); +let http = require('http'); let SMTPServer = require('smtp-server').SMTPServer; +let simpleParser = require('mailparser').simpleParser; let totalMessages = 0; let received = 0; +let mailstore = { + accounts: {}, + saveMessage(address, message) { + if (!this.accounts[address]) { + this.accounts[address] = []; + } + this.accounts[address].push(message); + }, + getMail(address, callback) { + if (!this.accounts[address] || this.accounts[address].length === 0) { + let err = new Error('No mail for ' + address); + err.status = 404; + return callback(err); + } + simpleParser(this.accounts[address].shift(), (err, mail) => { + if (err) { + return callback(err.message || err); + } + callback(null, mail); + }); + } +}; + // Setup server let server = new SMTPServer({ @@ -74,8 +99,12 @@ let server = new SMTPServer({ // Handle message stream onData: (stream, session, callback) => { let hash = crypto.createHash('md5'); + let message = ''; stream.on('data', chunk => { hash.update(chunk); + if (/^keep/i.test(session.envelope.rcptTo[0].address)) { + message += chunk; + } }); stream.on('end', () => { let err; @@ -84,6 +113,12 @@ let server = new SMTPServer({ err.responseCode = 552; return callback(err); } + + // Store message for e2e tests + if (/^keep/i.test(session.envelope.rcptTo[0].address)) { + mailstore.saveMessage(session.envelope.rcptTo[0].address, message); + } + received++; callback(null, 'Message queued as ' + hash.digest('hex')); // accept the message once the stream is ended }); @@ -94,6 +129,41 @@ server.on('error', err => { log.error('Test SMTP', err.stack); }); +let mailBoxServer = http.createServer((req, res) => { + let renderer = data => ( + '' + data.title + '' + data.body + '' + ); + + let address = req.url.substring(1); + mailstore.getMail(address, (err, mail) => { + if (err) { + let html = renderer({ + title: 'error', + body: err.message || err + }); + res.writeHead(err.status || 500, { 'Content-Type': 'text/html' }); + return res.end(html); + } + + let html = mail.html || renderer({ + title: 'error', + body: 'This mail has no HTML part' + }); + + // https://nodemailer.com/extras/mailparser/#mail-object + delete mail.html; + delete mail.textAsHtml; + delete mail.attachments; + + let script = ''; + html = html.replace(/<\/body\b/i, match => script + match); + html = html.replace(/target="_blank"/g, 'target="_self"'); + + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(html); + }); +}); + module.exports = callback => { if (config.testserver.enabled) { server.listen(config.testserver.port, config.testserver.host, () => { @@ -112,7 +182,10 @@ module.exports = callback => { } }, 60 * 1000); - setImmediate(callback); + mailBoxServer.listen(config.testserver.mailboxserverport, config.testserver.host, () => { + log.info('Test SMTP', 'Mail Box Server listening on port %s', config.testserver.mailboxserverport); + setImmediate(callback); + }); }); } else { setImmediate(callback); diff --git a/setup/install-centos7.sh b/setup/install-centos7.sh index 9ec03089..ff38cd73 100755 --- a/setup/install-centos7.sh +++ b/setup/install-centos7.sh @@ -12,7 +12,7 @@ set -e yum -y install epel-release -curl --silent --location https://rpm.nodesource.com/setup_6.x | bash - +curl --silent --location https://rpm.nodesource.com/setup_7.x | bash - yum -y install mariadb-server nodejs ImageMagick git python redis pwgen bind-utils gcc-c++ make systemctl start mariadb @@ -225,4 +225,3 @@ systemctl start zone-mta.service systemctl start mailtrain.service echo "Success! Open http://$HOSTNAME/ and log in as admin:test"; - diff --git a/setup/install.sh b/setup/install.sh index 8feadcfb..ce94918f 100755 --- a/setup/install.sh +++ b/setup/install.sh @@ -12,7 +12,7 @@ set -e export DEBIAN_FRONTEND=noninteractive -curl -sL https://deb.nodesource.com/setup_6.x | bash - +curl -sL https://deb.nodesource.com/setup_7.x | bash - apt-get -q -y install mariadb-server pwgen nodejs imagemagick git ufw build-essential dnsutils python software-properties-common apt-add-repository -y ppa:chris-lea/redis-server diff --git a/setup/sql/drop.js b/setup/sql/drop.js index ba0291d2..3e972fb3 100644 --- a/setup/sql/drop.js +++ b/setup/sql/drop.js @@ -1,17 +1,23 @@ 'use strict'; -if (process.env.NODE_ENV === 'production') { - console.log('This script does not run in production'); // eslint-disable-line no-console - process.exit(1); -} - let config = require('config'); let spawn = require('child_process').spawn; let log = require('npmlog'); let path = require('path'); +let fs = require('fs'); log.level = 'verbose'; +if (process.env.NODE_ENV === 'production') { + log.error('sqldrop', 'This script does not run in production'); + process.exit(1); +} + +if (process.env.NODE_ENV === 'test' && !fs.existsSync(path.join(__dirname, '..', '..', 'config', 'test.toml'))) { + log.error('sqldrop', 'This script only runs in test if config/test.toml (i.e. a dedicated test database) is present'); + process.exit(1); +} + function createDump(callback) { let cmd = spawn(path.join(__dirname, 'drop.sh'), [], { env: { diff --git a/setup/sql/init.js b/setup/sql/init.js index c3a638e3..c654b451 100644 --- a/setup/sql/init.js +++ b/setup/sql/init.js @@ -1,15 +1,22 @@ 'use strict'; -if (process.env.NODE_ENV === 'production') { - console.log('This script does not run in production'); // eslint-disable-line no-console - process.exit(1); -} - let dbcheck = require('../../lib/dbcheck'); let log = require('npmlog'); +let path = require('path'); +let fs = require('fs'); log.level = 'verbose'; +if (process.env.NODE_ENV === 'production') { + log.error('sqlinit', 'This script does not run in production'); + process.exit(1); +} + +if (process.env.NODE_ENV === 'test' && !fs.existsSync(path.join(__dirname, '..', '..', 'config', 'test.toml'))) { + log.error('sqlinit', 'This script only runs in test if config/test.toml (i.e. a dedicated test database) is present'); + process.exit(1); +} + dbcheck(err => { if (err) { log.error('DB', err); diff --git a/setup/sql/mailtrain-test.sql b/setup/sql/mailtrain-test.sql new file mode 100644 index 00000000..8ae97db9 --- /dev/null +++ b/setup/sql/mailtrain-test.sql @@ -0,0 +1,1023 @@ +SET UNIQUE_CHECKS=0; +SET FOREIGN_KEY_CHECKS=0; + +CREATE TABLE `attachments` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `campaign` int(11) unsigned NOT NULL, + `filename` varchar(255) CHARACTER SET utf8mb4 NOT NULL DEFAULT '', + `content_type` varchar(100) CHARACTER SET ascii NOT NULL DEFAULT '', + `content` longblob, + `size` int(11) NOT NULL, + `created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `campaign` (`campaign`), + CONSTRAINT `attachments_ibfk_1` FOREIGN KEY (`campaign`) REFERENCES `campaigns` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8; +CREATE TABLE `blacklist` ( + `email` varchar(191) NOT NULL, + PRIMARY KEY (`email`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE `campaign` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `list` int(11) unsigned NOT NULL, + `segment` int(11) unsigned NOT NULL, + `subscription` int(11) unsigned NOT NULL, + `status` tinyint(4) unsigned NOT NULL DEFAULT '0', + `response` varchar(255) DEFAULT NULL, + `response_id` varchar(255) CHARACTER SET ascii DEFAULT NULL, + `updated` timestamp NULL DEFAULT NULL, + `created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `list` (`list`,`segment`,`subscription`), + KEY `created` (`created`), + KEY `response_id` (`response_id`), + KEY `status_index` (`status`), + KEY `subscription_index` (`subscription`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE `campaign_tracker` ( + `list` int(11) unsigned NOT NULL, + `subscriber` int(11) unsigned NOT NULL, + `link` int(11) NOT NULL, + `ip` varchar(100) CHARACTER SET ascii DEFAULT NULL, + `device_type` varchar(50) DEFAULT NULL, + `country` varchar(2) CHARACTER SET ascii DEFAULT NULL, + `count` int(11) unsigned NOT NULL DEFAULT '1', + `created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`list`,`subscriber`,`link`), + KEY `created_index` (`created`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE `campaigns` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `cid` varchar(255) CHARACTER SET ascii NOT NULL, + `type` tinyint(4) unsigned NOT NULL DEFAULT '1', + `parent` int(11) unsigned DEFAULT NULL, + `name` varchar(255) NOT NULL DEFAULT '', + `description` text, + `list` int(11) unsigned NOT NULL, + `segment` int(11) unsigned DEFAULT NULL, + `template` int(11) unsigned NOT NULL, + `source_url` varchar(255) CHARACTER SET ascii DEFAULT NULL, + `editor_name` varchar(50) DEFAULT '', + `editor_data` longtext, + `last_check` timestamp NULL DEFAULT NULL, + `check_status` varchar(255) DEFAULT NULL, + `from` varchar(255) DEFAULT '', + `address` varchar(255) DEFAULT '', + `reply_to` varchar(255) DEFAULT '', + `subject` varchar(255) DEFAULT '', + `html` longtext, + `html_prepared` longtext, + `text` longtext, + `status` tinyint(4) unsigned NOT NULL DEFAULT '1', + `tracking_disabled` tinyint(4) unsigned NOT NULL DEFAULT '0', + `scheduled` timestamp NULL DEFAULT NULL, + `status_change` timestamp NULL DEFAULT NULL, + `delivered` int(11) unsigned NOT NULL DEFAULT '0', + `blacklisted` int(11) unsigned NOT NULL DEFAULT '0', + `opened` int(11) unsigned NOT NULL DEFAULT '0', + `clicks` int(11) unsigned NOT NULL DEFAULT '0', + `unsubscribed` int(11) unsigned NOT NULL DEFAULT '0', + `bounced` int(1) unsigned NOT NULL DEFAULT '0', + `complained` int(1) unsigned NOT NULL DEFAULT '0', + `created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `cid` (`cid`), + KEY `name` (`name`(191)), + KEY `status` (`status`), + KEY `schedule_index` (`scheduled`), + KEY `type_index` (`type`), + KEY `parent_index` (`parent`), + KEY `check_index` (`last_check`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE `confirmations` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `cid` varchar(255) CHARACTER SET ascii NOT NULL, + `list` int(11) unsigned NOT NULL, + `email` varchar(255) NOT NULL, + `opt_in_ip` varchar(100) DEFAULT NULL, + `data` text NOT NULL, + `created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `cid` (`cid`), + KEY `list` (`list`), + CONSTRAINT `confirmations_ibfk_1` FOREIGN KEY (`list`) REFERENCES `lists` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE `custom_fields` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `list` int(11) unsigned NOT NULL, + `name` varchar(255) DEFAULT '', + `key` varchar(100) CHARACTER SET ascii NOT NULL, + `default_value` varchar(255) DEFAULT NULL, + `type` varchar(255) CHARACTER SET ascii NOT NULL DEFAULT '', + `group` int(11) unsigned DEFAULT NULL, + `group_template` text, + `column` varchar(255) CHARACTER SET ascii DEFAULT NULL, + `visible` tinyint(4) unsigned NOT NULL DEFAULT '1', + `created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `list` (`list`,`column`), + KEY `list_2` (`list`), + CONSTRAINT `custom_fields_ibfk_1` FOREIGN KEY (`list`) REFERENCES `lists` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE `custom_forms` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `list` int(11) unsigned NOT NULL, + `name` varchar(255) DEFAULT '', + `description` text, + `fields_shown_on_subscribe` varchar(255) DEFAULT '', + `fields_shown_on_manage` varchar(255) DEFAULT '', + `layout` longtext, + `form_input_style` longtext, + `created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `list` (`list`), + CONSTRAINT `custom_forms_ibfk_1` FOREIGN KEY (`list`) REFERENCES `lists` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE `custom_forms_data` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `form` int(11) unsigned NOT NULL, + `data_key` varchar(255) DEFAULT '', + `data_value` longtext, + PRIMARY KEY (`id`), + KEY `form` (`form`), + CONSTRAINT `custom_forms_data_ibfk_1` FOREIGN KEY (`form`) REFERENCES `custom_forms` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE `import_failed` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `import` int(11) unsigned NOT NULL, + `email` varchar(255) NOT NULL DEFAULT '', + `reason` varchar(255) DEFAULT NULL, + `created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `import` (`import`), + CONSTRAINT `import_failed_ibfk_1` FOREIGN KEY (`import`) REFERENCES `importer` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE `importer` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `list` int(11) unsigned NOT NULL, + `type` tinyint(4) unsigned NOT NULL DEFAULT '1', + `path` varchar(255) NOT NULL DEFAULT '', + `size` int(11) unsigned NOT NULL DEFAULT '0', + `delimiter` varchar(1) CHARACTER SET ascii NOT NULL DEFAULT ',', + `emailcheck` tinyint(4) unsigned NOT NULL DEFAULT '1', + `status` tinyint(4) unsigned NOT NULL DEFAULT '0', + `error` varchar(255) DEFAULT NULL, + `processed` int(11) unsigned NOT NULL DEFAULT '0', + `new` int(11) unsigned NOT NULL DEFAULT '0', + `failed` int(11) unsigned NOT NULL DEFAULT '0', + `created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `mapping` text NOT NULL, + `finished` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `list` (`list`), + CONSTRAINT `importer_ibfk_1` FOREIGN KEY (`list`) REFERENCES `lists` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE `links` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `cid` varchar(255) CHARACTER SET ascii NOT NULL DEFAULT '', + `campaign` int(11) unsigned NOT NULL, + `url` varchar(255) CHARACTER SET ascii NOT NULL DEFAULT '', + `clicks` int(11) unsigned NOT NULL DEFAULT '0', + PRIMARY KEY (`id`), + UNIQUE KEY `cid` (`cid`), + UNIQUE KEY `campaign_2` (`campaign`,`url`), + KEY `campaign` (`campaign`), + CONSTRAINT `links_ibfk_1` FOREIGN KEY (`campaign`) REFERENCES `campaigns` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE `lists` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `cid` varchar(255) CHARACTER SET ascii NOT NULL, + `default_form` int(11) unsigned DEFAULT NULL, + `name` varchar(255) NOT NULL DEFAULT '', + `description` text, + `subscribers` int(11) unsigned DEFAULT '0', + `created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `public_subscribe` tinyint(1) unsigned NOT NULL DEFAULT '1', + PRIMARY KEY (`id`), + UNIQUE KEY `cid` (`cid`), + KEY `name` (`name`(191)) +) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4; +INSERT INTO `lists` (`id`, `cid`, `default_form`, `name`, `description`, `subscribers`, `created`, `public_subscribe`) VALUES (1,'Hkj1vCoJb',NULL,'01 Testlist - Public Subscribe','',0,NOW(),1); +CREATE TABLE `queued` ( + `campaign` int(11) unsigned NOT NULL, + `list` int(11) unsigned NOT NULL, + `subscriber` int(11) unsigned NOT NULL, + `source` varchar(255) DEFAULT NULL, + `created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`campaign`,`list`,`subscriber`), + KEY `created` (`created`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; +CREATE TABLE `report_templates` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(255) DEFAULT '', + `mime_type` varchar(255) NOT NULL DEFAULT 'text/html', + `description` text, + `user_fields` longtext, + `js` longtext, + `hbs` longtext, + `created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE `reports` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(255) DEFAULT '', + `description` text, + `report_template` int(11) unsigned NOT NULL, + `params` longtext, + `state` int(11) unsigned NOT NULL DEFAULT '0', + `last_run` datetime DEFAULT NULL, + `created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `report_template` (`report_template`), + CONSTRAINT `report_template_ibfk_1` FOREIGN KEY (`report_template`) REFERENCES `report_templates` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE `rss` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `parent` int(11) unsigned NOT NULL, + `guid` varchar(255) NOT NULL DEFAULT '', + `pubdate` timestamp NULL DEFAULT NULL, + `campaign` int(11) unsigned DEFAULT NULL, + `found` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `parent_2` (`parent`,`guid`), + KEY `parent` (`parent`), + CONSTRAINT `rss_ibfk_1` FOREIGN KEY (`parent`) REFERENCES `campaigns` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8; +CREATE TABLE `segment_rules` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `segment` int(11) unsigned NOT NULL, + `column` varchar(255) CHARACTER SET ascii NOT NULL DEFAULT '', + `value` varchar(255) NOT NULL DEFAULT '', + PRIMARY KEY (`id`), + KEY `segment` (`segment`), + CONSTRAINT `segment_rules_ibfk_1` FOREIGN KEY (`segment`) REFERENCES `segments` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE `segments` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `list` int(11) unsigned NOT NULL, + `name` varchar(255) NOT NULL DEFAULT '', + `type` tinyint(4) unsigned NOT NULL, + `created` timestamp NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `list` (`list`), + KEY `name` (`name`(191)), + CONSTRAINT `segments_ibfk_1` FOREIGN KEY (`list`) REFERENCES `lists` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE `settings` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `key` varchar(255) CHARACTER SET ascii NOT NULL DEFAULT '', + `value` text NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `key` (`key`) +) ENGINE=InnoDB AUTO_INCREMENT=112 DEFAULT CHARSET=utf8mb4; +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (1,'smtp_hostname','localhost'); +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (2,'smtp_port','5587'); +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (3,'smtp_encryption','NONE'); +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (4,'smtp_user','testuser'); +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (5,'smtp_pass','testpass'); +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (6,'service_url','http://localhost:3000/'); +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (7,'admin_email','admin@example.com'); +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (8,'smtp_max_connections','5'); +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (9,'smtp_max_messages','100'); +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (10,'smtp_log',''); +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (11,'default_sender','My Awesome Company'); +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (12,'default_postaddress','1234 Main Street'); +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (13,'default_from','My Awesome Company'); +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (14,'default_address','admin@example.com'); +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (15,'default_subject','Test message'); +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (16,'default_homepage','https://mailtrain.org'); +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (17,'db_schema_version','27'); +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (46,'ua_code',''); +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (47,'shoutout',''); +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (54,'mail_transport','smtp'); +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (60,'ses_key',''); +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (61,'ses_secret',''); +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (62,'ses_region','us-east-1'); +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (65,'smtp_throttling',''); +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (66,'pgp_passphrase',''); +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (67,'pgp_private_key',''); +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (68,'dkim_api_key',''); +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (69,'dkim_domain',''); +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (70,'dkim_selector',''); +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (71,'dkim_private_key',''); +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (73,'smtp_self_signed',''); +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (74,'smtp_disable_auth',''); +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (75,'verp_use',''); +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (76,'disable_wysiwyg',''); +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (77,'disable_confirmations',''); +CREATE TABLE `subscription` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `cid` varchar(255) CHARACTER SET ascii NOT NULL, + `email` varchar(255) CHARACTER SET utf8 NOT NULL DEFAULT '', + `opt_in_ip` varchar(100) DEFAULT NULL, + `opt_in_country` varchar(2) DEFAULT NULL, + `tz` varchar(100) CHARACTER SET ascii DEFAULT NULL, + `imported` int(11) unsigned DEFAULT NULL, + `status` tinyint(4) unsigned NOT NULL DEFAULT '1', + `is_test` tinyint(4) unsigned NOT NULL DEFAULT '0', + `status_change` timestamp NULL DEFAULT NULL, + `latest_open` timestamp NULL DEFAULT NULL, + `latest_click` timestamp NULL DEFAULT NULL, + `created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `first_name` varchar(255) DEFAULT NULL, + `last_name` varchar(255) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `email` (`email`), + UNIQUE KEY `cid` (`cid`), + KEY `status` (`status`), + KEY `first_name` (`first_name`(191)), + KEY `last_name` (`last_name`(191)), + KEY `subscriber_tz` (`tz`), + KEY `is_test` (`is_test`), + KEY `latest_open` (`latest_open`), + KEY `latest_click` (`latest_click`), + KEY `created` (`created`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE `subscription__1` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `cid` varchar(255) CHARACTER SET ascii NOT NULL, + `email` varchar(255) CHARACTER SET utf8 NOT NULL DEFAULT '', + `opt_in_ip` varchar(100) DEFAULT NULL, + `opt_in_country` varchar(2) DEFAULT NULL, + `tz` varchar(100) CHARACTER SET ascii DEFAULT NULL, + `imported` int(11) unsigned DEFAULT NULL, + `status` tinyint(4) unsigned NOT NULL DEFAULT '1', + `is_test` tinyint(4) unsigned NOT NULL DEFAULT '0', + `status_change` timestamp NULL DEFAULT NULL, + `latest_open` timestamp NULL DEFAULT NULL, + `latest_click` timestamp NULL DEFAULT NULL, + `created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `first_name` varchar(255) DEFAULT NULL, + `last_name` varchar(255) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `email` (`email`), + UNIQUE KEY `cid` (`cid`), + KEY `status` (`status`), + KEY `first_name` (`first_name`(191)), + KEY `last_name` (`last_name`(191)), + KEY `subscriber_tz` (`tz`), + KEY `is_test` (`is_test`), + KEY `latest_open` (`latest_open`), + KEY `latest_click` (`latest_click`), + KEY `created` (`created`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE `templates` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(255) NOT NULL DEFAULT '', + `description` text, + `editor_name` varchar(50) DEFAULT '', + `editor_data` longtext, + `html` longtext, + `text` longtext, + `created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `name` (`name`(191)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE `trigger` ( + `list` int(11) unsigned NOT NULL, + `subscription` int(11) unsigned NOT NULL, + `created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`list`,`subscription`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; +CREATE TABLE `triggers` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(255) NOT NULL DEFAULT '', + `description` text, + `enabled` tinyint(4) unsigned NOT NULL DEFAULT '1', + `list` int(11) unsigned NOT NULL, + `source_campaign` int(11) unsigned DEFAULT NULL, + `rule` varchar(255) CHARACTER SET ascii NOT NULL DEFAULT 'column', + `column` varchar(255) CHARACTER SET ascii DEFAULT NULL, + `seconds` int(11) NOT NULL DEFAULT '0', + `dest_campaign` int(11) unsigned DEFAULT NULL, + `count` int(11) unsigned NOT NULL DEFAULT '0', + `last_check` timestamp NULL DEFAULT NULL, + `created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `name` (`name`(191)), + KEY `source_campaign` (`source_campaign`), + KEY `dest_campaign` (`dest_campaign`), + KEY `list` (`list`), + KEY `column` (`column`), + KEY `active` (`enabled`), + KEY `last_check` (`last_check`), + CONSTRAINT `triggers_ibfk_1` FOREIGN KEY (`list`) REFERENCES `lists` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE `tzoffset` ( + `tz` varchar(100) NOT NULL DEFAULT '', + `offset` int(11) NOT NULL DEFAULT '0', + PRIMARY KEY (`tz`) +) ENGINE=InnoDB DEFAULT CHARSET=ascii; +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/abidjan',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/accra',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/addis_ababa',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/algiers',60); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/asmara',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/asmera',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/bamako',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/bangui',60); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/banjul',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/bissau',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/blantyre',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/brazzaville',60); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/bujumbura',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/cairo',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/casablanca',60); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/ceuta',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/conakry',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/dakar',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/dar_es_salaam',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/djibouti',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/douala',60); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/el_aaiun',60); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/freetown',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/gaborone',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/harare',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/johannesburg',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/juba',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/kampala',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/khartoum',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/kigali',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/kinshasa',60); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/lagos',60); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/libreville',60); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/lome',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/luanda',60); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/lubumbashi',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/lusaka',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/malabo',60); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/maputo',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/maseru',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/mbabane',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/mogadishu',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/monrovia',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/nairobi',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/ndjamena',60); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/niamey',60); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/nouakchott',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/ouagadougou',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/porto-novo',60); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/sao_tome',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/timbuktu',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/tripoli',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/tunis',60); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/windhoek',60); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/adak',-540); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/anchorage',-480); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/anguilla',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/antigua',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/araguaina',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/argentina/buenos_aires',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/argentina/catamarca',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/argentina/comodrivadavia',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/argentina/cordoba',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/argentina/jujuy',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/argentina/la_rioja',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/argentina/mendoza',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/argentina/rio_gallegos',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/argentina/salta',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/argentina/san_juan',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/argentina/san_luis',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/argentina/tucuman',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/argentina/ushuaia',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/aruba',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/asuncion',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/atikokan',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/atka',-540); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/bahia',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/bahia_banderas',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/barbados',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/belem',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/belize',-360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/blanc-sablon',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/boa_vista',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/bogota',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/boise',-360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/buenos_aires',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/cambridge_bay',-360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/campo_grande',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/cancun',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/caracas',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/catamarca',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/cayenne',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/cayman',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/chicago',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/chihuahua',-360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/coral_harbour',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/cordoba',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/costa_rica',-360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/creston',-420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/cuiaba',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/curacao',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/danmarkshavn',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/dawson',-420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/dawson_creek',-420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/denver',-360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/detroit',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/dominica',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/edmonton',-360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/eirunepe',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/el_salvador',-360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/ensenada',-420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/fortaleza',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/fort_nelson',-420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/fort_wayne',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/glace_bay',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/godthab',-120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/goose_bay',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/grand_turk',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/grenada',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/guadeloupe',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/guatemala',-360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/guayaquil',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/guyana',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/halifax',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/havana',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/hermosillo',-420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/indiana/indianapolis',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/indiana/knox',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/indiana/marengo',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/indiana/petersburg',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/indiana/tell_city',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/indiana/vevay',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/indiana/vincennes',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/indiana/winamac',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/indianapolis',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/inuvik',-360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/iqaluit',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/jamaica',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/jujuy',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/juneau',-480); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/kentucky/louisville',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/kentucky/monticello',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/knox_in',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/kralendijk',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/la_paz',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/lima',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/los_angeles',-420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/louisville',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/lower_princes',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/maceio',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/managua',-360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/manaus',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/marigot',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/martinique',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/matamoros',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/mazatlan',-360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/mendoza',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/menominee',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/merida',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/metlakatla',-480); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/mexico_city',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/miquelon',-120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/moncton',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/monterrey',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/montevideo',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/montreal',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/montserrat',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/nassau',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/new_york',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/nipigon',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/nome',-480); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/noronha',-120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/north_dakota/beulah',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/north_dakota/center',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/north_dakota/new_salem',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/ojinaga',-360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/panama',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/pangnirtung',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/paramaribo',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/phoenix',-420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/port-au-prince',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/porto_acre',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/porto_velho',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/port_of_spain',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/puerto_rico',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/punta_arenas',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/rainy_river',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/rankin_inlet',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/recife',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/regina',-360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/resolute',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/rio_branco',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/rosario',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/santarem',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/santa_isabel',-420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/santiago',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/santo_domingo',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/sao_paulo',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/scoresbysund',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/shiprock',-360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/sitka',-480); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/st_barthelemy',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/st_johns',-150); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/st_kitts',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/st_lucia',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/st_thomas',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/st_vincent',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/swift_current',-360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/tegucigalpa',-360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/thule',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/thunder_bay',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/tijuana',-420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/toronto',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/tortola',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/vancouver',-420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/virgin',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/whitehorse',-420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/winnipeg',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/yakutat',-480); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/yellowknife',-360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('antarctica/casey',660); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('antarctica/davis',420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('antarctica/dumontdurville',600); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('antarctica/macquarie',660); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('antarctica/mawson',300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('antarctica/mcmurdo',720); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('antarctica/palmer',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('antarctica/rothera',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('antarctica/south_pole',720); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('antarctica/syowa',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('antarctica/troll',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('antarctica/vostok',360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('arctic/longyearbyen',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/aden',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/almaty',360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/amman',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/anadyr',720); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/aqtau',300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/aqtobe',300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/ashgabat',300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/ashkhabad',300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/atyrau',300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/baghdad',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/bahrain',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/baku',240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/bangkok',420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/barnaul',420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/beirut',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/bishkek',360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/brunei',480); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/calcutta',330); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/chita',540); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/choibalsan',480); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/chongqing',480); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/chungking',480); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/colombo',330); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/dacca',360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/damascus',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/dhaka',360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/dili',540); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/dubai',240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/dushanbe',300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/famagusta',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/gaza',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/harbin',480); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/hebron',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/hong_kong',480); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/hovd',420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/ho_chi_minh',420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/irkutsk',480); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/istanbul',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/jakarta',420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/jayapura',540); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/jerusalem',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/kabul',270); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/kamchatka',720); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/karachi',300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/kashgar',360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/kathmandu',345); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/katmandu',345); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/khandyga',540); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/kolkata',330); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/krasnoyarsk',420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/kuala_lumpur',480); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/kuching',480); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/kuwait',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/macao',480); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/macau',480); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/magadan',660); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/makassar',480); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/manila',480); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/muscat',240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/nicosia',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/novokuznetsk',420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/novosibirsk',420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/omsk',360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/oral',300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/phnom_penh',420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/pontianak',420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/pyongyang',510); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/qatar',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/qyzylorda',360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/rangoon',390); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/riyadh',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/saigon',420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/sakhalin',660); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/samarkand',300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/seoul',540); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/shanghai',480); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/singapore',480); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/srednekolymsk',660); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/taipei',480); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/tashkent',300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/tbilisi',240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/tehran',270); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/tel_aviv',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/thimbu',360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/thimphu',360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/tokyo',540); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/tomsk',420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/ujung_pandang',480); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/ulaanbaatar',480); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/ulan_bator',480); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/urumqi',360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/ust-nera',600); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/vientiane',420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/vladivostok',600); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/yakutsk',540); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/yangon',390); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/yekaterinburg',300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/yerevan',240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('atlantic/azores',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('atlantic/bermuda',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('atlantic/canary',60); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('atlantic/cape_verde',-60); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('atlantic/faeroe',60); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('atlantic/faroe',60); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('atlantic/jan_mayen',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('atlantic/madeira',60); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('atlantic/reykjavik',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('atlantic/south_georgia',-120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('atlantic/stanley',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('atlantic/st_helena',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('australia/act',600); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('australia/adelaide',570); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('australia/brisbane',600); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('australia/broken_hill',570); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('australia/canberra',600); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('australia/currie',600); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('australia/darwin',570); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('australia/eucla',525); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('australia/hobart',600); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('australia/lhi',630); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('australia/lindeman',600); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('australia/lord_howe',630); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('australia/melbourne',600); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('australia/north',570); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('australia/nsw',600); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('australia/perth',480); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('australia/queensland',600); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('australia/south',570); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('australia/sydney',600); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('australia/tasmania',600); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('australia/victoria',600); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('australia/west',480); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('australia/yancowinna',570); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('brazil/acre',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('brazil/denoronha',-120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('brazil/east',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('brazil/west',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('canada/atlantic',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('canada/central',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('canada/east-saskatchewan',-360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('canada/eastern',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('canada/mountain',-360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('canada/newfoundland',-150); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('canada/pacific',-420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('canada/saskatchewan',-360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('canada/yukon',-420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('cet',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('chile/continental',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('chile/easterisland',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('cst6cdt',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('cuba',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('eet',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('egypt',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('eire',60); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('est',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('est5edt',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('etc/gmt',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('etc/gmt+0',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('etc/gmt+1',-60); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('etc/gmt+10',-600); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('etc/gmt+11',-660); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('etc/gmt+12',-720); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('etc/gmt+2',-120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('etc/gmt+3',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('etc/gmt+4',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('etc/gmt+5',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('etc/gmt+6',-360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('etc/gmt+7',-420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('etc/gmt+8',-480); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('etc/gmt+9',-540); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('etc/gmt-0',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('etc/gmt-1',60); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('etc/gmt-10',600); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('etc/gmt-11',660); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('etc/gmt-12',720); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('etc/gmt-13',780); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('etc/gmt-14',840); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('etc/gmt-2',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('etc/gmt-3',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('etc/gmt-4',240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('etc/gmt-5',300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('etc/gmt-6',360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('etc/gmt-7',420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('etc/gmt-8',480); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('etc/gmt-9',540); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('etc/gmt0',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('etc/greenwich',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('etc/uct',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('etc/universal',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('etc/utc',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('etc/zulu',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/amsterdam',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/andorra',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/astrakhan',240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/athens',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/belfast',60); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/belgrade',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/berlin',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/bratislava',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/brussels',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/bucharest',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/budapest',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/busingen',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/chisinau',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/copenhagen',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/dublin',60); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/gibraltar',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/guernsey',60); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/helsinki',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/isle_of_man',60); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/istanbul',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/jersey',60); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/kaliningrad',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/kiev',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/kirov',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/lisbon',60); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/ljubljana',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/london',60); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/luxembourg',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/madrid',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/malta',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/mariehamn',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/minsk',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/monaco',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/moscow',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/nicosia',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/oslo',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/paris',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/podgorica',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/prague',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/riga',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/rome',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/samara',240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/san_marino',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/sarajevo',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/saratov',240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/simferopol',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/skopje',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/sofia',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/stockholm',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/tallinn',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/tirane',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/tiraspol',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/ulyanovsk',240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/uzhgorod',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/vaduz',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/vatican',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/vienna',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/vilnius',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/volgograd',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/warsaw',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/zagreb',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/zaporozhye',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/zurich',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('gb',60); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('gb-eire',60); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('gmt',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('gmt+0',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('gmt-0',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('gmt0',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('greenwich',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('hongkong',480); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('hst',-600); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('iceland',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('indian/antananarivo',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('indian/chagos',360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('indian/christmas',420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('indian/cocos',390); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('indian/comoro',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('indian/kerguelen',300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('indian/mahe',240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('indian/maldives',300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('indian/mauritius',240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('indian/mayotte',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('indian/reunion',240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('iran',270); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('israel',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('jamaica',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('japan',540); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('kwajalein',720); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('libya',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('met',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('mexico/bajanorte',-420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('mexico/bajasur',-360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('mexico/general',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('mst',-420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('mst7mdt',-360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('navajo',-360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('nz',720); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('nz-chat',765); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/apia',780); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/auckland',720); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/bougainville',660); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/chatham',765); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/chuuk',600); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/easter',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/efate',660); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/enderbury',780); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/fakaofo',780); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/fiji',720); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/funafuti',720); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/galapagos',-360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/gambier',-540); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/guadalcanal',660); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/guam',600); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/honolulu',-600); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/johnston',-600); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/kiritimati',840); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/kosrae',660); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/kwajalein',720); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/majuro',720); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/marquesas',-570); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/midway',-660); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/nauru',720); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/niue',-660); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/norfolk',660); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/noumea',660); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/pago_pago',-660); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/palau',540); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/pitcairn',-480); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/pohnpei',660); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/ponape',660); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/port_moresby',600); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/rarotonga',-600); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/saipan',600); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/samoa',-660); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/tahiti',-600); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/tarawa',720); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/tongatapu',780); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/truk',600); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/wake',720); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/wallis',720); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/yap',600); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('poland',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('portugal',60); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('prc',480); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pst8pdt',-420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('roc',480); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('rok',540); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('singapore',480); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('turkey',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('uct',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('universal',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('us/alaska',-480); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('us/aleutian',-540); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('us/arizona',-420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('us/central',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('us/east-indiana',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('us/eastern',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('us/hawaii',-600); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('us/indiana-starke',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('us/michigan',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('us/mountain',-360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('us/pacific',-420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('us/pacific-new',-420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('us/samoa',-660); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('utc',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('w-su',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('wet',60); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('zulu',0); +CREATE TABLE `users` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `username` varchar(255) NOT NULL DEFAULT '', + `password` varchar(255) NOT NULL DEFAULT '', + `email` varchar(255) CHARACTER SET utf8 DEFAULT NULL, + `access_token` varchar(40) DEFAULT NULL, + `reset_token` varchar(255) CHARACTER SET ascii DEFAULT NULL, + `reset_expire` timestamp NULL DEFAULT NULL, + `created` timestamp NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `email` (`email`), + KEY `username` (`username`(191)), + KEY `reset` (`reset_token`), + KEY `check_reset` (`username`(191),`reset_token`,`reset_expire`), + KEY `token_index` (`access_token`) +) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4; +INSERT INTO `users` (`id`, `username`, `password`, `email`, `access_token`, `reset_token`, `reset_expire`, `created`) VALUES (1,'admin','$2a$10$mzKU71G62evnGB2PvQA4k..Wf9jASk.c7a8zRMHh6qQVjYJ2r/g/K','admin@example.com',NULL,NULL,NULL,NOW()); + +SET UNIQUE_CHECKS=1; +SET FOREIGN_KEY_CHECKS=1; diff --git a/test/e2e/.eslintrc b/test/e2e/.eslintrc new file mode 100644 index 00000000..836bac9a --- /dev/null +++ b/test/e2e/.eslintrc @@ -0,0 +1,11 @@ +{ + "parser": "babel-eslint", + "rules": { + "strict": 0, + "no-invalid-this": 0, + "no-unused-expressions": 0 + }, + "env": { + "mocha": true + } +} diff --git a/test/e2e/README.md b/test/e2e/README.md new file mode 100644 index 00000000..e4ed24fb --- /dev/null +++ b/test/e2e/README.md @@ -0,0 +1,44 @@ +# e2e Tests + +Running e2e tests requires Node 7.6 or later and a dedicated test database. It uses mocha, selenium-webdriver and phantomjs. + +## Installation + +These e2e tests have to be performed against predefined resources (e.g. lists, users, etc.) and therefore a dedicated test database and test config is required. + +Both can be created by running `sudo sh test/e2e/install.sh` from within your mailtrain directory. This creates a MYSQL user and database called `mailtrain_test`, and generates the required `config/test.toml`. + +## Running e2e Tests + +For tests to succeed Mailtrian must be started in `test` mode on port 3000 (as http://localhost:3000/ is the predefined service url). The tests itself have to be started in a second Terminal window. + +1. Start Mailtrain with `npm run starttest` +2. Start e2e tests with `npm run e2e` + +## Using Different Browsers + +By default e2e tests use `phantomjs`. If you want to use a different browser you need to install its driver and adjust your `config/test.toml`. + +* Install the `firefox` driver with `npm install geckodriver` +* Install the `chrome` driver with `npm install chromedriver` +* Other drivers can be found [here](https://seleniumhq.github.io/selenium/docs/api/javascript/) + +Then adjust your config: + +``` +[seleniumwebdriver] +browser="firefox" +``` + +Current Firefox issue (and patch): https://github.com/mozilla/geckodriver/issues/683 + +## Writing e2e Tests + +You should spend your time on features rather than writing tests, yet in some cases, like for example the subscription process, manual testing is just silly. You best get started by reading the current test suites, or just open an issue describing the scenario you want to get tested. + +Available commands: + +* `npm run sqldumptest` - exports the test DB to `setup/sql/mailtrain-test.sql` +* `npm run sqlresettest` - drops all tables then loads `setup/sql/mailtrain-test.sql` +* `npm run _e2e` - just runs e2e tests +* `npm run e2e` - runs `sqlresettest` then `_e2e` diff --git a/test/e2e/index.js b/test/e2e/index.js new file mode 100644 index 00000000..5dd54d00 --- /dev/null +++ b/test/e2e/index.js @@ -0,0 +1,30 @@ +'use strict'; + +require('./lib/exit-unless-test'); +const { mocha, driver } = require('./lib/mocha-e2e'); +const path = require('path'); + +global.USE_SHARED_DRIVER = true; + +const only = 'only'; +const skip = 'skip'; + +let tests = [ + 'login', + 'subscription' +]; + +tests = tests.map(testSpec => (testSpec.constructor === Array ? testSpec : [testSpec])); +tests = tests.filter(testSpec => testSpec[1] !== skip); +if (tests.some(testSpec => testSpec[1] === only)) { + tests = tests.filter(testSpec => testSpec[1] === only); +} + +for (const testSpec of tests) { + const testPath = path.join(__dirname, 'tests', testSpec[0] + '.js'); + mocha.addFile(testPath); +} + +mocha.run(failures => { + process.exit(failures); // exit with non-zero status if there were failures +}); diff --git a/test/e2e/install.sh b/test/e2e/install.sh new file mode 100644 index 00000000..860632aa --- /dev/null +++ b/test/e2e/install.sh @@ -0,0 +1,36 @@ +#!/bin/bash + +# This installation script works on Ubuntu 14.04 and 16.04 +# Run as root! + +if [[ $EUID -ne 0 ]]; then + echo "This script must be run as root" 1>&2 + exit 1 +fi + +set -e + +export DEBIAN_FRONTEND=noninteractive + +MYSQL_PASSWORD=`pwgen 12 -1` + +# Setup MySQL user for Mailtrain Tests +mysql -u root -e "CREATE USER 'mailtrain_test'@'localhost' IDENTIFIED BY '$MYSQL_PASSWORD';" +mysql -u root -e "GRANT ALL PRIVILEGES ON mailtrain_test.* TO 'mailtrain_test'@'localhost';" +mysql -u mailtrain_test --password="$MYSQL_PASSWORD" -e "CREATE database mailtrain_test;" + +# Setup installation configuration +cat >> config/test.toml < page({ + + async fetchMail(address) { + await driver.sleep(1000); + await driver.navigate().to(`${config.mailUrl}/${address}`); + await this.waitUntilVisible(); + }, + + async ensureUrl(path) { + throw new Error('Unsupported method.'); + }, + +}, ...extras); diff --git a/test/e2e/lib/mocha-e2e.js b/test/e2e/lib/mocha-e2e.js new file mode 100644 index 00000000..16aeba06 --- /dev/null +++ b/test/e2e/lib/mocha-e2e.js @@ -0,0 +1,217 @@ +'use strict'; + +const Mocha = require('mocha'); +const color = Mocha.reporters.Base.color; +const Semaphore = require('./semaphore'); +const fs = require('fs-extra'); +const config = require('./config'); +const webdriver = require('selenium-webdriver'); + +const driver = new webdriver.Builder() + .forBrowser(config.app.seleniumwebdriver.browser || 'phantomjs') + .build(); + + +const failHandlerRunning = new Semaphore(); + + +function UseCaseReporter(runner) { + Mocha.reporters.Base.call(this, runner); + + const self = this; + let indents = 0; + + function indent () { + return Array(indents).join(' '); + } + + runner.on('start', function () { + console.log(); + }); + + runner.on('suite', suite => { + ++indents; + console.log(color('suite', '%s%s'), indent(), suite.title); + }); + + runner.on('suite end', () => { + --indents; + if (indents === 1) { + console.log(); + } + }); + + runner.on('use-case', useCase => { + ++indents; + console.log(); + console.log(color('suite', '%sUse case: %s'), indent(), useCase.title); + }); + + runner.on('use-case end', () => { + --indents; + }); + + runner.on('steps', useCase => { + ++indents; + console.log(color('pass', '%s%s'), indent(), useCase.title); + }); + + runner.on('steps end', () => { + --indents; + }); + + runner.on('step pass', step => { + console.log(indent() + color('checkmark', ' ' + Mocha.reporters.Base.symbols.ok) + color('pass', ' %s'), step.title); + }); + + runner.on('step fail', step => { + console.log(indent() + color('fail', ' %s'), step.title); + }); + + runner.on('pending', test => { + const fmt = indent() + color('pending', ' - %s'); + console.log(fmt, test.title); + }); + + runner.on('pass', test => { + let fmt; + if (test.speed === 'fast') { + fmt = indent() + + color('checkmark', ' ' + Mocha.reporters.Base.symbols.ok) + + color('pass', ' %s'); + console.log(fmt, test.title); + } else { + fmt = indent() + + color('checkmark', ' ' + Mocha.reporters.Base.symbols.ok) + + color('pass', ' %s') + + color(test.speed, ' (%dms)'); + console.log(fmt, test.title, test.duration); + } + }); + + runner.on('fail', (test, err) => { + failHandlerRunning.enter(); + (async () => { + const currentUrl = await driver.getCurrentUrl(); + const info = `URL: ${currentUrl}`; + await fs.writeFile('last-failed-e2e-test.info', info); + await fs.writeFile('last-failed-e2e-test.html', await driver.getPageSource()); + await fs.writeFile('last-failed-e2e-test.png', new Buffer(await driver.takeScreenshot(), 'base64')); + failHandlerRunning.exit(); + })(); + + console.log(indent() + color('fail', ' %s'), test.title); + console.log(); + console.log(err); + console.log(); + console.log(`Snaphot of and info about the current page are in last-failed-e2e-test.*`); + }); + + runner.on('end', () => { + const stats = self.stats; + let fmt; + + console.log(); + + // passes + fmt = color('bright pass', ' ') + color('green', ' %d passing'); + console.log(fmt, stats.passes); + + // pending + if (stats.pending) { + fmt = color('pending', ' ') + color('pending', ' %d pending'); + console.log(fmt, stats.pending); + } + + // failures + if (stats.failures) { + fmt = color('fail', ' %d failing'); + console.log(fmt, stats.failures); + } + + console.log(); + }); +} + + +const mocha = new Mocha() + .timeout(120000) + .reporter(UseCaseReporter) + .ui('tdd'); + +mocha._originalRun = mocha.run; + + +let runner; +mocha.run = fn => { + runner = mocha._originalRun(async () => { + await failHandlerRunning.waitForEmpty(); + await driver.quit(); + + fn(); + }); +}; + + +async function useCaseExec(name, asyncFn) { + runner.emit('use-case', {title: name}); + + try { + await asyncFn(); + runner.emit('use-case end'); + } catch (err) { + runner.emit('use-case end'); + throw err; + } +} + +function useCase(name, asyncFn) { + if (asyncFn) { + return test('Use case: ' + name, () => useCaseExec(name, asyncFn)); + } else { + // Pending test + return test('Use case: ' + name); + } +} + +useCase.only = (name, asyncFn) => { + return test.only('Use case: ' + name, () => useCaseExec(name, asyncFn)); +}; + +useCase.skip = (name, asyncFn) => { + return test.skip('Use case: ' + name, () => useCaseExec(name, asyncFn)); +}; + +async function step(name, asyncFn) { + try { + await asyncFn(); + runner.emit('step pass', {title: name}); + } catch (err) { + runner.emit('step fail', {title: name}); + throw err; + } +} + +async function steps(name, asyncFn) { + try { + runner.emit('steps', {title: name}); + await asyncFn(); + runner.emit('steps end'); + } catch (err) { + runner.emit('step end'); + throw err; + } +} + +async function precondition(preConditionName, useCaseName, asyncFn) { + await steps(`Including use case "${useCaseName}" to satisfy precondition "${preConditionName}"`, asyncFn); +} + +module.exports = { + mocha, + useCase, + step, + steps, + precondition, + driver +}; \ No newline at end of file diff --git a/test/e2e/lib/page.js b/test/e2e/lib/page.js new file mode 100644 index 00000000..07064af7 --- /dev/null +++ b/test/e2e/lib/page.js @@ -0,0 +1,122 @@ +'use strict'; + +const config = require('./config'); +const webdriver = require('selenium-webdriver'); +const By = webdriver.By; +const until = webdriver.until; +const fs = require('fs-extra'); +const driver = require('./mocha-e2e').driver; +const url = require('url'); +const UrlPattern = require('url-pattern'); + +const waitTimeout = 10000; + +module.exports = (...extras) => Object.assign({ + elements: {}, + + async getElement(key) { + return await driver.findElement(By.css(this.elements[key])); + }, + + async getLinkParams(key) { + const elem = await driver.findElement(By.css(this.elements[key])); + + const linkUrl = await elem.getAttribute('href'); + const linkPath = url.parse(linkUrl).path; + + const urlPattern = new UrlPattern(this.links[key]); + + const params = urlPattern.match(linkPath); + if (!params) { + throw new Error(`Cannot match URL pattern ${this.links[key]}`); + } + return params; + }, + + async waitUntilVisible(selector) { + await driver.wait(until.elementLocated(By.css('body')), waitTimeout); + + for (const elem of (this.elementsToWaitFor || [])) { + const sel = this.elements[elem]; + if (!sel) { + throw new Error(`Element "${elem}" not found.`); + } + await driver.wait(until.elementLocated(By.css(sel)), waitTimeout); + } + + for (const text of (this.textsToWaitFor || [])) { + await driver.wait(new webdriver.Condition(`for text "${text}"`, async (driver) => { + return await this.containsText(text); + }), waitTimeout); + } + + if (this.url) { + await this.ensureUrl(); + } + + await driver.executeScript('document.mailTrainRefreshAcknowledged = true;'); + }, + + async waitUntilVisibleAfterRefresh(selector) { + await driver.wait(new webdriver.Condition('for refresh', async (driver) => { + const val = await driver.executeScript('return document.mailTrainRefreshAcknowledged;'); + return !val; + }), waitTimeout); + + await this.waitUntilVisible(selector); + }, + + async click(key) { + const elem = await this.getElement(key); + await elem.click(); + }, + + async getHref(key) { + const elem = await this.getElement(key); + return await elem.getAttribute('href'); + }, + + async getText(key) { + const elem = await this.getElement(key); + return await elem.getText(); + }, + + async getValue(key) { + const elem = await this.getElement(key); + return await elem.getAttribute('value'); + }, + + async containsText(str) { + return await driver.executeScript(` + return (document.documentElement.innerText || document.documentElement.textContent).indexOf('${str}') > -1; + `); + }, + + async getSource() { + return await driver.getPageSource(); + }, + + async saveSource(destPath) { + const src = await this.getSource(); + await fs.writeFile(destPath, src); + }, + + async saveScreenshot(destPath) { + const pngData = await driver.takeScreenshot(); + const buf = new Buffer(pngData, 'base64'); + await fs.writeFile(destPath, buf); + }, + + async saveSnapshot(destPathBase) { + destPathBase = destPathBase || 'last-failed-e2e-test'; + const currentUrl = await driver.getCurrentUrl(); + const info = `URL: ${currentUrl}`; + await fs.writeFile(destPathBase + '.info', info); + await this.saveSource(destPathBase + '.html'); + await this.saveScreenshot(destPathBase + '.png'); + }, + + async sleep(ms) { + await driver.sleep(ms); + } +}, ...extras); diff --git a/test/e2e/lib/semaphore.js b/test/e2e/lib/semaphore.js new file mode 100644 index 00000000..7c30c900 --- /dev/null +++ b/test/e2e/lib/semaphore.js @@ -0,0 +1,35 @@ +'use strict'; + +const Promise = require('bluebird'); + +class Semaphore { + constructor() { + this.counter = 0; + } + + enter() { + this.counter++; + } + + exit() { + this.counter--; + } + + async waitForEmpty() { + const self = this; + + function wait(resolve) { + if (self.counter == 0) { + resolve(); + } else { + setTimeout(wait, 500, resolve); + } + } + + return new Promise(resolve => { + setTimeout(wait, 500, resolve); + }) + } +} + +module.exports = Semaphore; \ No newline at end of file diff --git a/test/e2e/lib/web.js b/test/e2e/lib/web.js new file mode 100644 index 00000000..692c0214 --- /dev/null +++ b/test/e2e/lib/web.js @@ -0,0 +1,77 @@ +'use strict'; + +const config = require('./config'); +const By = require('selenium-webdriver').By; +const url = require('url'); +const UrlPattern = require('url-pattern'); +const driver = require('./mocha-e2e').driver; +const page = require('./page'); + +module.exports = (...extras) => page({ + + async navigate(pathOrParams) { + let path; + if (typeof pathOrParams === 'string') { + path = pathOrParams; + } else { + const urlPattern = new UrlPattern(this.requestUrl || this.url); + path = urlPattern.stringify(pathOrParams) + } + + const parsedUrl = url.parse(path); + let absolutePath; + if (parsedUrl.host) { + absolutePath = path; + } else { + absolutePath = config.baseUrl + path; + } + + await driver.navigate().to(absolutePath); + await this.waitUntilVisible(); + }, + + async ensureUrl(path) { + const desiredUrl = path || this.url; + + if (desiredUrl) { + const currentUrl = url.parse(await driver.getCurrentUrl()); + const urlPattern = new UrlPattern(desiredUrl); + const params = urlPattern.match(currentUrl.pathname); + if (!params || config.baseUrl !== `${currentUrl.protocol}//${currentUrl.host}`) { + throw new Error(`Unexpected URL. Expecting ${config.baseUrl}${this.url} got ${currentUrl.protocol}//${currentUrl.host}/${currentUrl.pathname}`); + } + + this.params = params; + } + }, + + async submit() { + const submitButton = await this.getElement('submitButton'); + await submitButton.click(); + }, + + async waitForFlash() { + await this.waitUntilVisible('div.alert:not(.js-warning)'); + }, + + async getFlash() { + const elem = await driver.findElement(By.css('div.alert:not(.js-warning)')); + return await elem.getText(); + }, + + async clearFlash() { + await driver.executeScript(` + var elements = document.getElementsByClassName('alert'); + while(elements.length > 0){ + elements[0].parentNode.removeChild(elements[0]); + } + `); + }, + + async setValue(key, value) { + const elem = await this.getElement(key); + await elem.clear(); + await elem.sendKeys(value); + } + +}, ...extras); diff --git a/test/e2e/page-objects/home.js b/test/e2e/page-objects/home.js new file mode 100644 index 00000000..3e33af24 --- /dev/null +++ b/test/e2e/page-objects/home.js @@ -0,0 +1,7 @@ +'use strict'; + +const web = require('../lib/web'); + +module.exports = web({ + url: '/' +}); diff --git a/test/e2e/page-objects/subscription.js b/test/e2e/page-objects/subscription.js new file mode 100644 index 00000000..1d431b49 --- /dev/null +++ b/test/e2e/page-objects/subscription.js @@ -0,0 +1,132 @@ +'use strict'; + +const config = require('../lib/config'); +const web = require('../lib/web'); +const mail = require('../lib/mail'); + +module.exports = list => ({ + + webSubscribe: web({ + url: `/subscription/${list.cid}`, + elementsToWaitFor: ['form'], + textsToWaitFor: ['Subscribe to list'], + elements: { + form: `form[action="/subscription/${list.cid}/subscribe"]`, + emailInput: '#main-form input[name="email"]', + firstNameInput: '#main-form input[name="first-name"]', + lastNameInput: '#main-form input[name="last-name"]', + submitButton: 'a[href="#submit"]' + } + }), + + webConfirmSubscriptionNotice: web({ + url: `/subscription/${list.cid}/confirm-subscription-notice`, + textsToWaitFor: ['We need to confirm your email address'] + }), + + mailConfirmSubscription: mail({ + elementsToWaitFor: ['confirmLink'], + textsToWaitFor: ['Please Confirm Subscription'], + elements: { + confirmLink: `a[href^="${config.settings['service-url']}subscription/confirm/subscribe/"]` + } + }), + + mailAlreadySubscribed: mail({ + elementsToWaitFor: ['unsubscribeLink'], + textsToWaitFor: ['Email address already registered'], + elements: { + unsubscribeLink: `a[href^="${config.settings['service-url']}subscription/${list.cid}/unsubscribe/"]`, + manageLink: `a[href^="${config.settings['service-url']}subscription/${list.cid}/manage/"]` + }, + links: { + unsubscribeLink: `/subscription/${list.cid}/unsubscribe/:ucid`, + manageLink: `/subscription/${list.cid}/manage/:ucid` + } + }), + + webSubscribedNotice: web({ + url: `/subscription/${list.cid}/subscribed-notice`, + textsToWaitFor: ['Subscription Confirmed'] + }), + + mailSubscriptionConfirmed: mail({ + elementsToWaitFor: ['unsubscribeLink'], + textsToWaitFor: ['Subscription Confirmed'], + elements: { + unsubscribeLink: `a[href^="${config.settings['service-url']}subscription/${list.cid}/unsubscribe/"]`, + manageLink: `a[href^="${config.settings['service-url']}subscription/${list.cid}/manage/"]` + }, + links: { + unsubscribeLink: `/subscription/${list.cid}/unsubscribe/:ucid`, + manageLink: `/subscription/${list.cid}/manage/:ucid` + } + }), + + webManage: web({ + url: `/subscription/${list.cid}/manage/:ucid`, + elementsToWaitFor: ['form'], + textsToWaitFor: ['Update Your Preferences'], + elements: { + form: `form[action="/subscription/${list.cid}/manage"]`, + emailInput: '#main-form input[name="email"]', + firstNameInput: '#main-form input[name="first-name"]', + lastNameInput: '#main-form input[name="last-name"]', + submitButton: 'a[href="#submit"]', + manageAddressLink: `a[href^="/subscription/${list.cid}/manage-address/"]` + }, + links: { + manageAddressLink: `/subscription/${list.cid}/manage-address/:ucid` + } + }), + + webManageAddress: web({ + url: `/subscription/${list.cid}/manage-address/:ucid`, + elementsToWaitFor: ['form'], + textsToWaitFor: ['Update Your Email Address'], + elements: { + form: `form[action="/subscription/${list.cid}/manage-address"]`, + emailInput: '#main-form input[name="email"]', + emailNewInput: '#main-form input[name="email-new"]', + submitButton: 'a[href="#submit"]', + } + }), + + mailConfirmAddressChange: mail({ + elementsToWaitFor: ['confirmLink'], + textsToWaitFor: ['Please Confirm Subscription Address Change'], + elements: { + confirmLink: `a[href^="${config.settings['service-url']}subscription/confirm/change-address/"]` + } + }), + + webUpdatedNotice: web({ + url: `/subscription/${list.cid}/updated-notice`, + textsToWaitFor: ['Profile Updated'], + }), + + webUnsubscribedNotice: web({ + url: `/subscription/${list.cid}/unsubscribed-notice`, + textsToWaitFor: ['Unsubscribe Successful'], + }), + + mailUnsubscriptionConfirmed: mail({ + elementsToWaitFor: ['resubscribeLink'], + textsToWaitFor: ['You Are Now Unsubscribed'], + elements: { + resubscribeLink: `a[href^="${config.settings['service-url']}subscription/${list.cid}"]` + } + }), + + /* + webUnsubscribe: web({ // FIXME + elementsToWaitFor: ['submitButton'], + elements: { + submitButton: 'a[href="#submit"]' + } + }), + +*/ + +}); + diff --git a/test/e2e/page-objects/user.js b/test/e2e/page-objects/user.js new file mode 100644 index 00000000..52f8377f --- /dev/null +++ b/test/e2e/page-objects/user.js @@ -0,0 +1,29 @@ +'use strict'; + +const web = require('../lib/web'); + +module.exports = { + login: web({ + url: '/users/login', + elementsToWaitFor: ['submitButton'], + elements: { + usernameInput: 'form[action="/users/login"] input[name="username"]', + passwordInput: 'form[action="/users/login"] input[name="password"]', + submitButton: 'form[action="/users/login"] [type=submit]' + } + }), + + logout: web({ + requestUrl: '/users/logout', + url: '/' + }), + + account: web({ + url: '/users/account', + elementsToWaitFor: ['form'], + elements: { + form: `form[action="/users/account"]`, + emailInput: 'form[action="/users/account"] input[name="email"]' + } + }), +}; diff --git a/test/e2e/tests/login.js b/test/e2e/tests/login.js new file mode 100644 index 00000000..571389be --- /dev/null +++ b/test/e2e/tests/login.js @@ -0,0 +1,68 @@ +'use strict'; + +const config = require('../lib/config'); +const { useCase, step, driver } = require('../lib/mocha-e2e'); +const expect = require('chai').expect; + +const page = require('../page-objects/user'); +const home = require('../page-objects/home'); + +suite('Login use-cases', function() { + before(() => driver.manage().deleteAllCookies()); + + test('User can access home page', async () => { + await home.navigate(); + }); + + test('Anonymous user cannot access restricted content', async () => { + await driver.navigate().to(config.baseUrl + '/settings'); + await page.login.waitUntilVisible(); + await page.login.waitForFlash(); + expect(await page.login.getFlash()).to.contain('Need to be logged in to access restricted content'); + }); + + useCase('Login (invalid credential)', async () => { + await step('User navigates to the login page.', async () => { + await page.login.navigate(); + }); + + await step('User fills in the user name and incorrect password.', async () => { + await page.login.setValue('usernameInput', config.users.admin.username); + await page.login.setValue('passwordInput', 'invalid'); + await page.login.submit(); + }); + + await step('System shows a flash notice that credentials are invalid.', async () => { + await page.login.waitForFlash(); + expect(await page.login.getFlash()).to.contain('Incorrect username or password'); + }); + }); + + useCase('Login and logout', async () => { + await step('User navigates to the login page.', async () => { + await page.login.navigate(); + }); + + await step('User fills in the user name and password.', async () => { + await page.login.setValue('usernameInput', config.users.admin.username); + await page.login.setValue('passwordInput', config.users.admin.password); + await page.login.submit(); + }); + + await step('System shows the home page and a flash notice that user has been logged in.', async () => { + await home.waitUntilVisibleAfterRefresh(); + await home.waitForFlash(); + expect(await home.getFlash()).to.contain('Logged in as admin'); + }); + + await step('User navigates to its account.', async () => { + await page.account.navigate(); + }); + + await step('User logs out.', async () => { + await page.logout.navigate(); + await home.waitForFlash(); + expect(await home.getFlash()).to.contain('logged out'); + }); + }); +}); diff --git a/test/e2e/tests/subscription.js b/test/e2e/tests/subscription.js new file mode 100644 index 00000000..fec05937 --- /dev/null +++ b/test/e2e/tests/subscription.js @@ -0,0 +1,248 @@ +'use strict'; + +const config = require('../lib/config'); +const { useCase, step, precondition, driver } = require('../lib/mocha-e2e'); +const shortid = require('shortid'); +const expect = require('chai').expect; + +const page = require('../page-objects/subscription')(config.lists.one); + +function generateEmail() { + return 'keep.' + shortid.generate() + '@mailtrain.org'; +} + +async function subscribe(subscription) { + await step('User navigates to list subscription page.', async () => { + await page.webSubscribe.navigate(); + }); + + await step('User submits a valid email and other subscription info.', async () => { + await page.webSubscribe.setValue('emailInput', subscription.email); + + if (subscription.firstName) { + await page.webSubscribe.setValue('firstNameInput', subscription.firstName); + } + + if (subscription.lastName) { + await page.webSubscribe.setValue('lastNameInput', subscription.lastName); + } + + await page.webSubscribe.submit(); + }); + + await step('System shows a notice that further instructions are in the email.', async () => { + await page.webConfirmSubscriptionNotice.waitUntilVisibleAfterRefresh(); + }); + + await step('System sends an email with a link to confirm the subscription.', async () => { + await page.mailConfirmSubscription.fetchMail(subscription.email); + }); + + await step('User clicks confirm subscription in the email', async () => { + await page.mailConfirmSubscription.click('confirmLink'); + }); + + await step('System shows a notice that subscription has been confirmed.', async () => { + await page.webSubscribedNotice.waitUntilVisibleAfterRefresh(); + }); + + await step('System sends an email with subscription confirmation.', async () => { + await page.mailSubscriptionConfirmed.fetchMail(subscription.email); + subscription.unsubscribeLink = await page.mailSubscriptionConfirmed.getHref('unsubscribeLink'); + subscription.manageLink = await page.mailSubscriptionConfirmed.getHref('manageLink'); + + const unsubscribeParams = await page.mailSubscriptionConfirmed.getLinkParams('unsubscribeLink'); + const manageParams = await page.mailSubscriptionConfirmed.getLinkParams('manageLink'); + expect(unsubscribeParams.ucid).to.equal(manageParams.ucid); + subscription.ucid = unsubscribeParams.ucid; + }); + + return subscription; +} + +async function subscriptionExistsPrecondition(subscription) { + await precondition('Subscription exists', 'Subscription to a public list (main scenario)', async () => { + await subscribe(subscription); + }); + return subscription; +} + +suite('Subscription use-cases', function() { + before(() => driver.manage().deleteAllCookies()); + + useCase('Subscription to a public list (main scenario)', async () => { + await subscribe({ + email: generateEmail() + }); + }); + + useCase('Subscription to a public list (invalid email)', async () => { + await step('User navigates to list subscribe page', async () => { + await page.webSubscribe.navigate(); + }); + + await step('User submits an invalid email.', async () => { + await page.webSubscribe.setValue('emailInput', 'foo@bar.nope'); + await page.webSubscribe.submit(); + }); + + await step('System shows a flash notice that email is invalid.', async () => { + await page.webSubscribe.waitForFlash(); + expect(await page.webSubscribe.getFlash()).to.contain('Invalid email address'); + }); + }); + + useCase('Subscription to a public list (email already registered)', async () => { + const subscription = await subscriptionExistsPrecondition({ + email: generateEmail() + }); + + await step('User navigates to list subscribe page', async () => { + await page.webSubscribe.navigate(); + }); + + await step('User submits the email which has been already registered.', async () => { + await page.webSubscribe.setValue('emailInput', subscription.email); + await page.webSubscribe.submit(); + }); + + await step('System shows a notice that further instructions are in the email.', async () => { + await page.webConfirmSubscriptionNotice.waitUntilVisibleAfterRefresh(); + }); + + await step('System sends an email informing that the address has been already registered.', async () => { + await page.mailAlreadySubscribed.fetchMail(subscription.email); + }); + + }); + + useCase('Subscription to a non-public list'); + + useCase('Change profile info', async () => { + const subscription = await subscriptionExistsPrecondition({ + email: generateEmail(), + firstName: 'John', + lastName: 'Doe' + }); + + await step('User clicks the manage subscription button.', async () => { + await page.mailSubscriptionConfirmed.click('manageLink'); + }); + + await step('Systems shows a form to change subscription details. The form contains data entered during subscription.', async () => { + await page.webManage.waitUntilVisibleAfterRefresh(); + expect(await page.webManage.getValue('emailInput')).to.equal(subscription.email); + expect(await page.webManage.getValue('firstNameInput')).to.equal(subscription.firstName); + expect(await page.webManage.getValue('lastNameInput')).to.equal(subscription.lastName); + }); + + await step('User enters another name and submits the form.', async () => { + subscription.firstName = 'Adam'; + subscription.lastName = 'B'; + await page.webManage.setValue('firstNameInput', subscription.firstName); + await page.webManage.setValue('lastNameInput', subscription.lastName); + await page.webManage.submit(); + }); + + await step('Systems shows a notice that profile has been updated.', async () => { + await page.webUpdatedNotice.waitUntilVisibleAfterRefresh(); + }); + + await step('User navigates to manage subscription again.', async () => { + // await page.webManage.navigate(subscription.manageLink); + await page.webManage.navigate({ ucid: subscription.ucid }); + }); + + await step('Systems shows a form with the changes made previously.', async () => { + expect(await page.webManage.getValue('emailInput')).to.equal(subscription.email); + expect(await page.webManage.getValue('firstNameInput')).to.equal(subscription.firstName); + expect(await page.webManage.getValue('lastNameInput')).to.equal(subscription.lastName); + }); + }); + + useCase('Change email', async () => { + const subscription = await subscriptionExistsPrecondition({ + email: generateEmail(), + firstName: 'John', + lastName: 'Doe' + }); + + await step('User clicks the manage subscription button.', async () => { + await page.mailSubscriptionConfirmed.click('manageLink'); + }); + + await step('Systems shows a form to change subscription details. The form contains data entered during subscription.', async () => { + await page.webManage.waitUntilVisibleAfterRefresh(); + expect(await page.webManage.getValue('emailInput')).to.equal(subscription.email); + expect(await page.webManage.getValue('firstNameInput')).to.equal(subscription.firstName); + expect(await page.webManage.getValue('lastNameInput')).to.equal(subscription.lastName); + }); + + await step('User clicks the change address button.', async () => { + await page.webManage.click('manageAddressLink'); + }); + + await step('Systems shows a form to change email.', async () => { + await page.webManageAddress.waitUntilVisibleAfterRefresh(); + }); + + await step('User fills in a new email address and submits the form.', async () => { + subscription.email = generateEmail(); + await page.webManageAddress.setValue('emailNewInput', subscription.email); + await page.webManageAddress.submit(); + }); + + await step('System goes back to the profile form and shows a flash notice that further instructions are in the email.', async () => { + await page.webManage.waitUntilVisibleAfterRefresh(); + await page.webManage.waitForFlash(); + expect(await page.webManage.getFlash()).to.contain('An email with further instructions has been sent to the provided address'); + }); + + await step('System sends an email with a link to confirm the address change.', async () => { + await page.mailConfirmAddressChange.fetchMail(subscription.email); + }); + + await step('User clicks confirm subscription in the email', async () => { + await page.mailConfirmAddressChange.click('confirmLink'); + }); + + await step('System shows the profile form with a flash notice that address has been changed.', async () => { + await page.webManage.waitUntilVisibleAfterRefresh(); + await page.webManage.waitForFlash(); + expect(await page.webManage.getFlash()).to.contain('Email address changed'); + expect(await page.webManage.getValue('emailInput')).to.equal(subscription.email); + }); + + await step('System sends an email with subscription confirmation.', async () => { + await page.mailSubscriptionConfirmed.fetchMail(subscription.email); + }); + }); + + useCase('Unsubscription from list #1 (one-step, no form).', async () => { + const subscription = await subscriptionExistsPrecondition({ + email: generateEmail() + }); + + await step('User clicks the unsubscribe button.', async () => { + await page.mailSubscriptionConfirmed.click('unsubscribeLink'); + }); + + await step('System shows a notice that confirms unsubscription.', async () => { + await page.webUnsubscribedNotice.waitUntilVisibleAfterRefresh(); + }); + + await step('System sends an email that confirms unsubscription.', async () => { + await page.mailUnsubscriptionConfirmed.fetchMail(subscription.email); + }); + }); + + useCase('Unsubscription from list #2 (one-step, with form).'); + + useCase('Unsubscription from list #3 (two-step, no form).'); + + useCase('Unsubscription from list #4 (two-step, with form).'); + + useCase('Unsubscription from list #5 (manual unsubscribe).'); + + useCase('Resubscription.'); // This one is supposed to check that values pre-filled in resubscription (i.e. the re-subscribe link in unsubscription confirmation) are the same as the ones used before. +}); diff --git a/test/frontmail-test.js b/test/nodeunit/frontmail-test.js similarity index 100% rename from test/frontmail-test.js rename to test/nodeunit/frontmail-test.js diff --git a/views/layout.hbs b/views/layout.hbs index e28568d8..4ac23b5a 100644 --- a/views/layout.hbs +++ b/views/layout.hbs @@ -53,7 +53,7 @@ - +