diff --git a/.gitignore b/.gitignore index c7771ae..2459a51 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ *.sqlite *.log venv/ +build.sh +CloudSession-Pkg.tar.gz ################# ## NetBeans @@ -16,3 +18,6 @@ nbactions.xml ################# .idea +build +deploy-test +/CloudSession-Templates.tar.gz diff --git a/app/AuthToken/controllers.py b/app/AuthToken/controllers.py index 5f0664a..0fb64fa 100644 --- a/app/AuthToken/controllers.py +++ b/app/AuthToken/controllers.py @@ -1,10 +1,9 @@ # Import the database object from the main app module -import json import logging import uuid import datetime - import Failures + from app import db from app.User import services as user_service diff --git a/app/AuthToken/models.py b/app/AuthToken/models.py index dd6e1d6..bea10aa 100644 --- a/app/AuthToken/models.py +++ b/app/AuthToken/models.py @@ -2,7 +2,6 @@ # We will define this inside /app/__init__.py in the next sections. from app import db - class AuthenticationToken(db.Model): id = db.Column(db.BigInteger, primary_key=True) id_user = db.Column(db.BigInteger, db.ForeignKey('user.id')) diff --git a/app/Authenticate/controllers.py b/app/Authenticate/controllers.py index 836517d..efc02a6 100644 --- a/app/Authenticate/controllers.py +++ b/app/Authenticate/controllers.py @@ -1,9 +1,7 @@ # Import the database object from the main app module import logging -import uuid -import datetime - import Failures + from app import db from app.User import services as user_services from app.RateLimiting import services as rate_limiting_services @@ -66,7 +64,11 @@ def post(self): 'email': user.email, 'locale': user.locale, 'screenname': user.screen_name, - 'authentication-source': user.auth_source + 'authentication-source': user.auth_source, + 'bdmonth': user.birth_month, + 'bdyear': user.birth_year, + 'parent-email': user.parent_email, + 'parent-email-source': user.parent_email_source }} api.add_resource(AuthenticateLocalUser, '/local') diff --git a/app/Email/services.py b/app/Email/services.py index 2fab71c..be15412 100644 --- a/app/Email/services.py +++ b/app/Email/services.py @@ -1,45 +1,92 @@ -from app import mail, app, db - +from app import mail, app from os.path import expanduser, isfile - from flask.ext.mail import Message +from app.User.coppa import Coppa, SponsorType import pystache import logging +""" +TODO: System documentation goes here +""" + + def send_email_template_for_user(id_user, template, server, **kwargs): from app.User.services import get_user - logging.info("Sending email to user: %s (%s)", id_user, template) + # Get a copy of the user record + logging.info("Checking for a valid user record for user ID: %s", id_user) + user = get_user(id_user) + + if user is None: + logging.error("Cannot send email: Invalid user record") + return False + else: + logging.info("Email template received user: %s", user.id) + + logging.info("Sending email to user: %s using template (%s)", user.id, template) params = {} for key, value in kwargs.items(): + logging.debug("Logging parameter %s = %s", key, value) params[key] = value - user = get_user(id_user) - if user is None: - return False + # The elements in the params array represent the data elements that are + # available to the email templates. params['screenname'] = user.screen_name + params['email'] = user.email + params['registrant-email'] = user.email + params['sponsoremail'] = user.parent_email + + #Default the recipient email address + user_email = user.email + coppa = Coppa() + + # Send email to parent if user is under 13 years old + if template == 'confirm' and coppa.is_coppa_covered(user.birth_month, user.birth_year): + # Send email only to the sponsor address + user_email = user.parent_email + logging.info("COPPA account has a sponsor type of %s", user.parent_email_source) + + if user.parent_email_source == SponsorType.TEACHER: + # Teacher handles the account confirmation + send_email_template_to_address(user_email, 'confirm-teacher', server, user.locale, params) + elif user.parent_email_source == SponsorType.PARENT or\ + user.parent_email_source == SponsorType.GUARDIAN: + # Parent handles the account confirmation + send_email_template_to_address(user_email, 'confirm-parent', server, user.locale, params) + else: + logging.info("COPPA account %s has invalid sponsor type [%s]", user.id, user.parent_email_source) + + return + else: + # Registration not subject to COPPA regulations + send_email_template_to_address(user_email, template, server, user.locale, params) - send_email_template_to_address(user.email, template, server, user.locale, params) + return def send_email_template_to_address(recipient, template, server, locale, params=None, **kwargs): - # Read templates params = params or {} + + # Add any supplied arguments to the parameter dictionary for key, value in kwargs.items(): params[key] = value + params['email'] = recipient params['locale'] = locale + # Read templates (subject, plain, rich) = _read_templates(template, server, locale, params) + logging.info("Sending email to %s", recipient) send_email(recipient, subject, plain, rich) def send_email(recipient, subject, email_text, rich_email_text=None): + msg = Message( recipients=[recipient], subject=subject.rstrip(), @@ -59,15 +106,27 @@ def _read_templates(template, server, locale, params): def _read_template(template, server, locale, part, params, none_if_missing=False): + """ + Render a mustache template. + + :param template: Base template name + :param server: Host server + :param locale: Language designator + :param part: Generic message type descriptor + :param params: Text string to replace tags embedded within the template + :param none_if_missing: Return 'none' if the requested template is not found + + :return: Upon success, return a Renderer object. Return none or a general + error message if the none_is_missing flag is false + """ template_file = expanduser("~/templates/%s/%s/%s/%s.mustache" % (locale, template, server, part)) if isfile(template_file): logging.debug('Looking for template file: %s', template_file) renderer = pystache.Renderer() rendered = renderer.render_path(template_file, params) - #print(rendered) return rendered else: - logging.error('Looking for template file: %s, but the file is missing', template_file) + logging.warn('Looking for template file: %s, but the file is missing', template_file) if none_if_missing: return None else: diff --git a/app/LocalUser/controllers.py b/app/LocalUser/controllers.py index ba0a45e..e3ec51d 100644 --- a/app/LocalUser/controllers.py +++ b/app/LocalUser/controllers.py @@ -46,7 +46,7 @@ def post(self): confirm_token = ConfirmToken.query.filter_by(token=token).first() if confirm_token is None: - # Unkown token + # Unknown token return {'success': False, 'code': 510} if confirm_token.id_user != user.id: # Token is not for this user @@ -65,9 +65,11 @@ def post(self): class RequestConfirm(Resource): def get(self, email): - # Get values + # Get server URL server = request.headers.get('server') + logging.info("Requesting email confirmation for %s from server %s", email, server) + # Validate required fields validation = Validation() validation.add_required_field('email', email) @@ -95,11 +97,18 @@ def get(self, email): else: if code == 10: return Failures.rate_exceeded() - return { - 'success': False, - 'message': message, - 'code': 520 - } + elif code == 99: + return { + 'success': False, + 'message': message, + 'code': 540 + } + else: + return { + 'success': False, + 'message': message, + 'code': 520 + } class PasswordReset(Resource): diff --git a/app/User/controllers.py b/app/User/controllers.py index c4c8dbc..769baf9 100644 --- a/app/User/controllers.py +++ b/app/User/controllers.py @@ -10,12 +10,14 @@ from Validation import Validation from app.User import services as user_service -from models import User +# from models import User +# Define the endpoint prefix for user services user_app = Blueprint('user', __name__, url_prefix='/user') api = Api(user_app) +# Register a new user class Register(Resource): def post(self): @@ -27,6 +29,12 @@ def post(self): locale = request.form.get('locale') screen_name = request.form.get('screenname') + # COPPA support + birth_month = request.form.get('bdmonth') + birth_year = request.form.get('bdyear') + parent_email = request.form.get('parent-email') + parent_email_source = request.form.get('parent-email-source') + # Validate required fields validation = Validation() validation.add_required_field('server', server) @@ -35,6 +43,16 @@ def post(self): validation.add_required_field('password-confirm', password_confirm) validation.add_required_field('locale', locale) validation.add_required_field('screenname', screen_name) + + # COPPA support + validation.add_required_field('bdmonth', birth_month) + validation.add_required_field('bdyear', birth_year) + if parent_email: + validation.check_email('parent-email', parent_email) + if not validation.is_valid(): + return validation.get_validation_response() + + # Verify user email address validation.check_email('email', email) if not validation.is_valid(): return validation.get_validation_response() @@ -55,15 +73,23 @@ def post(self): if not user_service.check_password_complexity(password): return Failures.password_complexity() - id_user = user_service.create_local_user(server, email, password, locale, screen_name) - user_service.send_email_confirm(id_user, server) - - db.session.commit() + # Write user details to the database + id_user = user_service.create_local_user( + server, email, password, locale, screen_name, + birth_month, birth_year, parent_email, parent_email_source) - logging.info('User-controller: register success: %s', id_user) + # Send a confirmation request email to user or parent + (result, errno, mesg) = user_service.send_email_confirm(id_user, server) + if result: + # Commit the database record + db.session.commit() + logging.info('User-controller: register success: %s', id_user) - # Create user - return {'success': True, 'user': id_user} + # Create user + return {'success': True, 'user': id_user} + else: + logging.error("Unable to register user. Error %s: %s", errno, mesg) + return {'success': False, 'user': 0} class GetUserById(Resource): @@ -72,7 +98,7 @@ def get(self, id_user): # Parse numbers try: id_user = int(id_user) - except: + except ValueError: return Failures.not_a_number('idUser', id_user) # Validate user exists, is validated and is not blocked @@ -87,7 +113,11 @@ def get(self, id_user): 'email': user.email, 'locale': user.locale, 'screenname': user.screen_name, - 'authentication-source': user.auth_source + 'authentication-source': user.auth_source, + 'bdmonth': user.birth_month, + 'bdyear': user.birth_year, + 'parent-email': user.parent_email, + 'parent-email-source': user.parent_email_source }} @@ -106,7 +136,11 @@ def get(self, email): 'email': user.email, 'locale': user.locale, 'screenname': user.screen_name, - 'authentication-source': user.auth_source + 'authentication-source': user.auth_source, + 'bdmonth': user.birth_month, + 'bdyear': user.birth_year, + 'parent-email': user.parent_email, + 'parent-email-source': user.parent_email_source }} @@ -125,7 +159,11 @@ def get(self, screen_name): 'email': user.email, 'locale': user.locale, 'screenname': user.screen_name, - 'authentication-source': user.auth_source + 'authentication-source': user.auth_source, + 'bdmonth': user.birth_month, + 'bdyear': user.birth_year, + 'parent-email': user.parent_email, + 'parent-email-source': user.parent_email_source }} @@ -133,6 +171,7 @@ class DoInfoChange(Resource): def post(self, id_user): screen_name = request.form.get('screenname') + # Validate required fields validation = Validation() validation.add_required_field('id-user', id_user) @@ -143,7 +182,7 @@ def post(self, id_user): # Parse numbers try: id_user = int(id_user) - except: + except ValueError: return Failures.not_a_number('idUser', id_user) # Validate user exists, is validated and is not blocked @@ -166,7 +205,11 @@ def post(self, id_user): 'email': user.email, 'locale': user.locale, 'screenname': user.screen_name, - 'authentication-source': user.auth_source + 'authentication-source': user.auth_source, + 'bdmonth': user.birth_month, + 'bdyear': user.birth_year, + 'parent-email': user.parent_email, + 'parent-email-source': user.parent_email_source }} @@ -174,6 +217,7 @@ class DoLocaleChange(Resource): def post(self, id_user): locale = request.form.get('locale') + # Validate required fields validation = Validation() validation.add_required_field('id-user', id_user) @@ -184,7 +228,7 @@ def post(self, id_user): # Parse numbers try: id_user = int(id_user) - except: + except ValueError: return Failures.not_a_number('idUser', id_user) # Validate user exists, is validated and is not blocked @@ -202,13 +246,28 @@ def post(self, id_user): 'email': user.email, 'locale': user.locale, 'screenname': user.screen_name, - 'authentication-source': user.auth_source + 'authentication-source': user.auth_source, + 'bdmonth': user.birth_month, + 'bdyear': user.birth_year, + 'parent-email': user.parent_email, + 'parent-email-source': user.parent_email_source }} +# Supported endpoints +# Note: The url_prefix is '/user'. All user endpoints are in the form +# of host:port/user/_service_ +# +# Register a new user account api.add_resource(Register, '/register') + +# Retrieve details about an existing user account api.add_resource(GetUserById, '/id/') api.add_resource(GetUserByEmail, '/email/') api.add_resource(GetUserByScreenname, '/screenname/') + +# Update a user screen name api.add_resource(DoInfoChange, '/info/') + +# Update the local defined in the user account api.add_resource(DoLocaleChange, '/locale/') diff --git a/app/User/coppa.py b/app/User/coppa.py new file mode 100644 index 0000000..88fa09a --- /dev/null +++ b/app/User/coppa.py @@ -0,0 +1,38 @@ + +import datetime + +# Enumerate the sponsor types for COPPA eligible user accounts +class SponsorType: + INDIVIDUAL = 0 + PARENT = 1 + GUARDIAN = 2 + TEACHER = 3 + + def __init__(self): + pass + + +class Coppa: + def __init__(self): + pass + + # Return true if the date is less than 13 years + def is_coppa_covered(self, month, year): + # This is the number of months a typical thirteen years old has been on planet Earth. + cap = 156 + + # This is the actual number of months a typical user has been on the same planet. + user_age = (year * 12) + month + + # Current year and month + current_month = datetime.date.today().month + current_year = datetime.date.today().year + + # This represents the number of months since the inception of AD + # Unless you want to count that first year as part of BC. + current_cap = (current_year * 12) + current_month + + if current_cap - user_age > cap: + return False + else: + return True diff --git a/app/User/models.py b/app/User/models.py index 08310b0..71ee9b2 100644 --- a/app/User/models.py +++ b/app/User/models.py @@ -12,6 +12,12 @@ class User(db.Model): confirmed = db.Column(db.Boolean) screen_name = db.Column(db.String(250)) + # COPPA support + birth_month = db.Column(db.INTEGER, nullable=False) + birth_year = db.Column(db.INTEGER, nullable=False) + parent_email = db.Column(db.String(250)) + parent_email_source = db.Column(db.INTEGER) + def __init__(self): self.blocked = False self.confirmed = False diff --git a/app/User/services.py b/app/User/services.py index 1a71da8..d72d0be 100644 --- a/app/User/services.py +++ b/app/User/services.py @@ -1,6 +1,6 @@ import hashlib import uuid - +import logging import datetime from app import db, app @@ -11,6 +11,10 @@ from models import User, ConfirmToken, ResetToken +def get_user(id_user): + return User.query.get(id_user) + + def get_password_hash(password): salt = str(uuid.uuid1()) password_hash = hashlib.sha256("%s:%s" % (password, salt)).hexdigest() @@ -23,10 +27,6 @@ def check_password(id_user, password): return user.password == password_hash -def get_user(id_user): - return User.query.get(id_user) - - def get_user_by_email(email): return User.query.filter_by(email=email).first() @@ -39,7 +39,10 @@ def check_password_complexity(password): return 8 <= len(password) < 200 -def create_local_user(server, email, password, locale, screen_name): +def create_local_user( + server, email, password, locale, screen_name, + birth_month, birth_year, parent_email, parent_email_source): + salt, password_hash = get_password_hash(password) # Save user @@ -51,6 +54,12 @@ def create_local_user(server, email, password, locale, screen_name): user.password = password_hash user.salt = salt + #COPPA support + user.birth_month = birth_month + user.birth_year = birth_year + user.parent_email = parent_email + user.parent_email_source = parent_email_source + db.session.add(user) db.session.flush() db.session.refresh(user) @@ -58,7 +67,10 @@ def create_local_user(server, email, password, locale, screen_name): return user.id -def create_oauth_user(server, email, source, locale, screen_name): +def create_oauth_user( + server, email, source, locale, screen_name, + birth_month, birth_year, parent_email, parent_email_source): + # Save user user = User() user.email = email @@ -68,6 +80,13 @@ def create_oauth_user(server, email, source, locale, screen_name): user.confirmed = True user.blocked = False + # COPPA support + user.birth_month = birth_month + user.birth_year = birth_year + user.parent_email = parent_email + user.parent_email_source = parent_email_source + + # Add the user record db.session.add(user) db.session.flush() db.session.refresh(user) @@ -76,16 +95,24 @@ def create_oauth_user(server, email, source, locale, screen_name): def send_email_confirm(id_user, server): + logging.info("Preparing new account confirmation email for user %s", id_user) + logging.info("Account request received from server: %s", server) + user = get_user(id_user) + if user is None: + logging.debug("Unknown user id: %s", id_user) return False, 1, 'User id not known' if user.confirmed: + logging.debug("User account %s has already been verified", id_user) return False, 2, 'Account already verified' if user.blocked: + logging.debug("User account %s has been blocked", id_user) return False, 3, 'Account Blocked' # check rate limiting if not rate_limiting_services.consume_tokens(id_user, 'email-confirm', 1): + logging.debug("Too many attempts to confirm account for user %s", id_user) return False, 10, 'Rate limiter exceeded' # Delete token if any exists @@ -103,7 +130,14 @@ def send_email_confirm(id_user, server): confirm_token.validity = datetime.datetime.now() + datetime.timedelta(hours=token_validity_time) db.session.add(confirm_token) - email_services.send_email_template_for_user(id_user, 'confirm', server, token=token) + try: + logging.info("Sending account confirmation email to user: %s ", id_user) + # Send an email to the user or user's responsible party to confirm the account request + email_services.send_email_template_for_user(id_user, 'confirm', server, token=token) + logging.info("Completed email send process.") + except Exception as ex: + logging.error("Error while sending email: %s", ex.message) + return False, 99, 'Unable to contact SMTP server' return True, 0, 'Success' @@ -137,3 +171,4 @@ def send_password_reset(id_user, server): email_services.send_email_template_for_user(id_user, 'reset', server, token=token) return True, 0, 'Success' + diff --git a/app/__init__.py b/app/__init__.py index 0ca2971..362949a 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,17 +1,19 @@ -# Import flask and template operators +""" +Cloud Session server application initialization + +""" # Import properties files utils import logging from ConfigParser import ConfigParser - -import os from FakeSecHead import FakeSecHead from os.path import expanduser, isfile # Import Flask -from flask import Flask, render_template +# from flask import Flask, render_template +from flask import Flask -# Import SQLAlchemy +# Import SQLAlchemy database mapper from flask.ext.sqlalchemy import SQLAlchemy # Import Mail @@ -21,6 +23,8 @@ from raven.contrib.flask import Sentry app = Flask(__name__) +version = "1.1.0" +db = None # Load basic configurations app.config.from_object('config') @@ -29,6 +33,8 @@ defaults = { 'database.url': 'mysql+mysqldb://cloudsession:cloudsession@localhost:3306/cloudsession', + 'request.host': 'http://localhost:8080/blockly', + 'sentry-dsn': None, 'mail.host': 'localhost', @@ -58,8 +64,11 @@ 'bucket.email-confirm.freq': '1800000' } +logging.basicConfig(level=logging.DEBUG) + configfile = expanduser("~/cloudsession.properties") -print('Looking for config file: %s' % configfile) +logging.info('Looking for config file: %s', configfile) + if isfile(configfile): configs = ConfigParser(defaults) configs.readfp(FakeSecHead(open(configfile))) @@ -67,14 +76,14 @@ app_configs = {} for (key, value) in configs.items('section'): app_configs[key] = value + app.config['CLOUD_SESSION_PROPERTIES'] = app_configs + else: app.config['CLOUD_SESSION_PROPERTIES'] = defaults # -------------------------------------- Module initialization ------------------------------------------------- -logging.basicConfig(level=logging.DEBUG) - if app.config['CLOUD_SESSION_PROPERTIES']['sentry-dsn'] is not None: logging.info("Initializing Sentry") sentry = Sentry(app, @@ -85,14 +94,14 @@ else: logging.info("No Sentry configuration") - -app.config['SQLALCHEMY_DATABASE_URI'] = app.config['CLOUD_SESSION_PROPERTIES']['database.url'] - # Define the database object which is imported # by modules and controllers -logging.info("Initializing database connection") +# logging.info("Initializing database connection") +app.config['SQLALCHEMY_DATABASE_URI'] = app.config['CLOUD_SESSION_PROPERTIES']['database.url'] db = SQLAlchemy(app) + +# logging.info("Configuring SMTP properties") app.config['MAIL_SERVER'] = app.config['CLOUD_SESSION_PROPERTIES']['mail.host'] if app.config['CLOUD_SESSION_PROPERTIES']['mail.port'] is None: if app.config['CLOUD_SESSION_PROPERTIES']['mail.tls']: @@ -101,6 +110,7 @@ app.config['MAIL_PORT'] = 25 else: app.config['MAIL_PORT'] = app.config['CLOUD_SESSION_PROPERTIES']['mail.port'] + app.config['MAIL_USE_TLS'] = app.config['CLOUD_SESSION_PROPERTIES']['mail.tls'] app.config['MAIL_USE_SSL'] = app.config['CLOUD_SESSION_PROPERTIES']['mail.ssl'] app.config['MAIL_DEBUG'] = app.config['CLOUD_SESSION_PROPERTIES']['mail.debug'] @@ -108,18 +118,27 @@ app.config['MAIL_PASSWORD'] = app.config['CLOUD_SESSION_PROPERTIES']['mail.password'] app.config['DEFAULT_MAIL_SENDER'] = app.config['CLOUD_SESSION_PROPERTIES']['mail.from'] -#app.debug = True + logging.info("Initializing mail") +logging.info("SMTP port: %s", app.config['MAIL_PORT']) +logging.info("TLS: %s",app.config['MAIL_USE_TLS']) +logging.info("SSL: %s",app.config['MAIL_USE_SSL']) +logging.info("Sender: %s",app.config['DEFAULT_MAIL_SENDER']) + mail = Mail(app) # -------------------------------------------- Services -------------------------------------------------------- logging.info("Initializing services") -from app.AuthToken.controllers import auth_token_app -from app.Authenticate.controllers import authenticate_app -from app.User.controllers import user_app -from app.LocalUser.controllers import local_user_app -from app.RateLimiting.controllers import rate_limiting_app -from app.OAuth.controllers import oauth_app + +# All of these imports need the database +if db is not None: + from app.Authenticate.controllers import authenticate_app + from app.AuthToken.controllers import auth_token_app + from app.User.controllers import user_app + from app.LocalUser.controllers import local_user_app + from app.RateLimiting.controllers import rate_limiting_app + from app.OAuth.controllers import oauth_app + app.register_blueprint(auth_token_app) app.register_blueprint(authenticate_app) diff --git a/cloudsession.properties.example b/cloudsession.properties.example new file mode 100644 index 0000000..f2898f3 --- /dev/null +++ b/cloudsession.properties.example @@ -0,0 +1,38 @@ +# +# Sample Cloud Session configuration file +# + +# Database connection +database.url = jdbc:mysql://database.example.com:3306/cloudsession +database.username = database_user +database.password = database_user_password + +# SMTP server configuration +mail.host = smtp.example.com +mail.from = no_reply@example.com +mail.authenticated = true +mail.user = authenticated_username +mail.password = authenticated_user_password +mail.tls = true + +# The email notification system relies on a number of templates +# to produce the email messages sent to users. +email.template.path = /usr/share/tomcat7/templates + +# Rate limiting +# Starting number of compiles +bucket.compile.size = 100 + +# Number of tokens added per interval +bucket.compile.input = 50 + +# Token add interval (ms) +bucket.compile.freq = 500000 + +# Enable detailed statistics on applicaiton operation +metrics.console.enable = false + +# Bucket types are objects that can be applied to the rate-limiting service built +# into the server. Bucket types are arbitrary units. There can be more than one +# bucket type defined. +bucket.types = compile diff --git a/requirements.txt b/requirements.txt index 9fd50b6..680ec6b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,3 +19,4 @@ SQLAlchemy==1.0.12 validate-email==1.3 Werkzeug==0.11.5 wheel==0.24.0 +validate_email==1.3 diff --git a/templates/en/confirm-parent/blocklyprop/header.mustache b/templates/en/confirm-parent/blocklyprop/header.mustache new file mode 100644 index 0000000..3425542 --- /dev/null +++ b/templates/en/confirm-parent/blocklyprop/header.mustache @@ -0,0 +1,2 @@ +{{! This is the email Subject line }} +New user registration confirmation request \ No newline at end of file diff --git a/templates/en/confirm-parent/blocklyprop/plain.mustache b/templates/en/confirm-parent/blocklyprop/plain.mustache new file mode 100644 index 0000000..3840702 --- /dev/null +++ b/templates/en/confirm-parent/blocklyprop/plain.mustache @@ -0,0 +1,49 @@ +{{! + This is the text body of the email new account notification to + a parent or guardian +}} +Hello, + +Someone, perhaps your child, has requested a new account on the Parallax +BlocklyProp web site (http://blockly.parallax.com) under the screen name +{{screenname}}. When the account was created, your email address was +received as the parent or guardian of the registrant. If this is incorrect, +please accept our apologies. There is nothing more you need to do. The +request will automatically expire. + +Why are we sending this? In the US, the federal Children's Online Privacy +Protection Act, known as COPPA, requires that we communicate with a parent +or guardian if the person registering an BlocklyProp account is under the +age of 13. The Act allows you to decline the registration request. If you +choose this option, we will immediately remove the registration information +and the associated account. If you choose to confirm the the request and +activate the account, you may close the account at any time by clicking on +the link provided below. This action will close the account and remove any +projects that are associated with the account. + +A full copy of our Child Privacy Policy is available online at: +http://localhost:8080/blockly/child-privacy-policy + + +If this is your child, please confirm the registration by copying the link below into +your browser or by navigating to the second link and confirming the student's email address. + +Confirm account registration: +Copy and paste into your browser +http://localhost:8080/blockly/confirm?locale={{locale}}&email={{registrant-email}}&token={{token}} to confirm your email address. + +If the above link is unable to complete your registration, please go to +http://localhost:8080/blockly/confirm and enter your email address and the token: {{token}} + + +You may also elect to cancel the registration process immediately. To do +so, please copy and paste the link below. This will remove the pending registration +request and the account that was created during the registration process. + +[NEED A CANCEL LINK HERE] + + + +Regards, + +The Parallax team diff --git a/templates/en/confirm-teacher/blocklyprop/header.mustache b/templates/en/confirm-teacher/blocklyprop/header.mustache new file mode 100644 index 0000000..7447513 --- /dev/null +++ b/templates/en/confirm-teacher/blocklyprop/header.mustache @@ -0,0 +1,2 @@ +{{! This is the email Subject line }} +Please confirm your student's email address for BlocklyProp diff --git a/templates/en/confirm-teacher/blocklyprop/plain.mustache b/templates/en/confirm-teacher/blocklyprop/plain.mustache new file mode 100644 index 0000000..1e76d32 --- /dev/null +++ b/templates/en/confirm-teacher/blocklyprop/plain.mustache @@ -0,0 +1,22 @@ +{{! + This is the text body of the new account confirmation email directed to instructors +}} +Hello, + +A student has created an account on the Parallax BlocklyProp web site under the +screen name {{screenname}}. When the account was created, your email address was +registered as the classroom instructor. If this is incorrect, please accept our +apologies. There is nothing more you need to do. The request will automatically expire. + +If this is your student, please confirm the registration by copying the link below into +your browser or by navigating to the second link and confirming the student's email address. + +Copy and paste into your browser +http://localhost:8080/blockly/confirm?locale={{locale}}&email={{registrant-email}}&token={{token}} to confirm your email address. + +If the above link is unable to complete your registration, please go to +http://localhost:8080/blockly/confirm and enter your student's email address ({{registrant-email}}) and the token: {{token}} + +Regards, + +The Parallax team diff --git a/templates/en/confirm/blocklyprop/header.mustache b/templates/en/confirm/blocklyprop/header.mustache new file mode 100644 index 0000000..ed52201 --- /dev/null +++ b/templates/en/confirm/blocklyprop/header.mustache @@ -0,0 +1 @@ +{{! This is the email Subject line }}Please confirm your email address for BlocklyProp diff --git a/templates/en/confirm/blocklyprop/plain.mustache b/templates/en/confirm/blocklyprop/plain.mustache new file mode 100644 index 0000000..023480c --- /dev/null +++ b/templates/en/confirm/blocklyprop/plain.mustache @@ -0,0 +1,9 @@ +{{! This is the text body of the email}} +Dear {{screenname}}, + +Please go to http://localhost:8080/blockly/confirm?locale={{locale}}&email={{email}}&token={{token}} to confirm your email address. + +If the url does not work, please go to http://localhost:8080/blockly/confirm and enter your email address and the token: {{token}} + + +The Parallax team diff --git a/templates/en/reset/blocklyprop/header.mustache b/templates/en/reset/blocklyprop/header.mustache new file mode 100644 index 0000000..630929b --- /dev/null +++ b/templates/en/reset/blocklyprop/header.mustache @@ -0,0 +1 @@ +Reset your BlocklyProp password diff --git a/templates/en/reset/blocklyprop/plain.mustache b/templates/en/reset/blocklyprop/plain.mustache new file mode 100644 index 0000000..9a5e5f5 --- /dev/null +++ b/templates/en/reset/blocklyprop/plain.mustache @@ -0,0 +1,11 @@ +Dear {{screenname}}, + +A request was made to reset your BlocklyProp account password. + +If this request was made by you, please go to http://localost:8080/blockly/reset?locale={{locale}}&email={{email}}&token={{token}} to reset your password now. + +If the url does not work, please go to http://localhost:8080/blockly/reset and use the token: {{token}} + +If you did not make this request, you may safely ignore this message. + +The Parallax team diff --git a/templates/en_US/confirm/blocklyprop/header.mustache b/templates/en_US/confirm/blocklyprop/header.mustache new file mode 100644 index 0000000..8d1f20a --- /dev/null +++ b/templates/en_US/confirm/blocklyprop/header.mustache @@ -0,0 +1 @@ +Please confirm your email address for BlocklyProp diff --git a/templates/en_US/confirm/blocklyprop/plain.mustache b/templates/en_US/confirm/blocklyprop/plain.mustache new file mode 100644 index 0000000..ff39fcb --- /dev/null +++ b/templates/en_US/confirm/blocklyprop/plain.mustache @@ -0,0 +1,8 @@ +Dear {{screenname}}, + +Please go to http://localhost:8080/blockly/confirm?locale={{locale}}&email={{email}}&token={{token}} to confirm your email address. + +If the url does not work, please go to http://localhost:8080/blockly/confirm and enter your email address and the token: {{token}} + + +The Parallax team diff --git a/templates/en_US/reset/blocklyprop/header.mustache b/templates/en_US/reset/blocklyprop/header.mustache new file mode 100644 index 0000000..630929b --- /dev/null +++ b/templates/en_US/reset/blocklyprop/header.mustache @@ -0,0 +1 @@ +Reset your BlocklyProp password diff --git a/templates/en_US/reset/blocklyprop/plain.mustache b/templates/en_US/reset/blocklyprop/plain.mustache new file mode 100644 index 0000000..59f709e --- /dev/null +++ b/templates/en_US/reset/blocklyprop/plain.mustache @@ -0,0 +1,11 @@ +Dear {{screenname}}, + +A request was made to reset your BlocklyProp account password. + +If this request was made by you, please go to http://localhost:8080/blockly/reset?locale={{locale}}&email={{email}}&token={{token}} to reset your password now. + +If the url does not work, please go to http://localhost:8080/blockly/reset and use the token: {{token}} + +If you did not make this request, you may safely ignore this message. + +The Parallax team