diff --git a/.gitignore b/.gitignore index c6e8634d8..d57130880 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ deploy/ dist/ +.vscode/ artifact_go.sh +*.exe diff --git a/Dockerfile b/Dockerfile index 928255a93..7cb6718c6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,19 @@ # Development -FROM golang:1.9.2-alpine AS development +FROM golang:1.10.2-alpine AS development WORKDIR /go/src/github.com/tidepool-org/hydrophone COPY . . +RUN apk --no-cache update && \ + apk --no-cache upgrade && \ + apk add build-base git cyrus-sasl-dev rsync -RUN ./build.sh +RUN dos2unix build.sh && ./build.sh && \ + dos2unix test.sh && \ + dos2unix env.sh && \ + dos2unix artifact.sh && \ + dos2unix start.sh && \ + dos2unix version.sh CMD ["./dist/hydrophone"] @@ -15,6 +23,7 @@ FROM alpine:latest AS release RUN apk --no-cache update && \ apk --no-cache upgrade && \ apk add --no-cache ca-certificates && \ + apk add --no-cache libsasl && \ adduser -D tidepool WORKDIR /home/tidepool @@ -22,5 +31,8 @@ WORKDIR /home/tidepool USER tidepool COPY --from=development --chown=tidepool /go/src/github.com/tidepool-org/hydrophone/dist/hydrophone . +COPY --chown=tidepool templates/html ./templates/html/ +COPY --chown=tidepool templates/locales ./templates/locales/ +COPY --chown=tidepool templates/meta ./templates/meta/ CMD ["./hydrophone"] diff --git a/README.md b/README.md index 182e124e1..a29e0704f 100644 --- a/README.md +++ b/README.md @@ -3,4 +3,53 @@ hydrophone [![Build Status](https://travis-ci.org/tidepool-org/hydrophone.png)](https://travis-ci.org/tidepool-org/hydrophone) -This API sends notifications to users for things like forgotten passwords, initial signup, and invitations. +This API sends notifications (using relevant language) to users for things like forgotten passwords, initial signup, and invitations. + +## Building +To build the Hydrophone module you simply need to execute the build script: + +``` +$ ./build.sh +``` +This will automatically get the dependencies (using goget) and build the code. + + +## Running the Tests +If you would like to contribute then you will likely need to run the tests locally before pushing your changes. +To run **all** tests you can simply execute the test script from your favorite shell: + +`$ ./test.sh` + +To run the tests for a particular folder (i.e. the api part) you need to go into this folder and execute the gotest command: +To run all tests for this repo then in the root directory use: + +``` +$ cd ./api +$ gotest +``` + + +## Config +The configuration is provided to Hydrophone via 2 environment variables: `TIDEPOOL_HYDROPHONE_ENV` and `TIDEPOOL_HYDROPHONE_SERVICE`. +The script `env.sh` provided in this repo will set all the necessary variables with default values, allowing you to work on your development environment. However when deploying on another environment, or when using docker you will likely need to change these variables to match your setup. + +## Notes on email customization and internationalization +More information on this in [docs/README.md](docs/README.md) + +The emails sent by Hydrophone can be customized and translated in the user's language. +The templates and locales files are located under /templates: +* /templates/html: html template files +* /templates/locales: content in various languages +* /templates/meta: email structure + +**Configuration note:** you do need to provide the template location to Hydrophone in the environment variables as an absolute path. relative path won't work. +For example: +``` +export TIDEPOOL_HYDROPHONE_SERVICE='{ + "hydrophone" : { + ... + "i18nTemplatesPath": "/var/data/hydrophone/templates" + }, +... +}' +``` diff --git a/api/forgot.go b/api/forgot.go index 314d9b2a0..3a639bd6a 100644 --- a/api/forgot.go +++ b/api/forgot.go @@ -41,6 +41,12 @@ type ( // status: 200 // status: 400 no email given func (a *Api) passwordReset(res http.ResponseWriter, req *http.Request, vars map[string]string) { + // By default, the reseter language will be his browser's or "en" for Englih + // In case the reseter is found a known user and has a language set, the language will be overriden in a later step + var reseterLanguage string + if reseterLanguage = getBrowserPreferredLanguage(req); reseterLanguage == "" { + reseterLanguage = "en" + } email := vars["useremail"] if email == "" { @@ -51,8 +57,23 @@ func (a *Api) passwordReset(res http.ResponseWriter, req *http.Request, vars map resetCnf, _ := models.NewConfirmation(models.TypePasswordReset, models.TemplateNamePasswordReset, "") resetCnf.Email = email + // if the reseter is already a Tidepool user, we can use his preferences if resetUsr := a.findExistingUser(resetCnf.Email, a.sl.TokenProvide()); resetUsr != nil { resetCnf.UserId = resetUsr.UserID + + // let's get the reseter user preferences + reseterPreferences := &models.Preferences{} + if err := a.seagull.GetCollection(resetCnf.UserId, "preferences", a.sl.TokenProvide(), reseterPreferences); err != nil { + a.sendError(res, http.StatusInternalServerError, + STATUS_ERR_FINDING_USR, + "forgot password: error getting reseter user preferences: ", + err.Error()) + return + } + // if reseter has a profile and a language we override the previously set language (browser's or "en") + if reseterPreferences.DisplayLanguage != "" { + reseterLanguage = reseterPreferences.DisplayLanguage + } } else { log.Print(STATUS_RESET_NO_ACCOUNT) log.Printf("email used [%s]", email) @@ -70,7 +91,7 @@ func (a *Api) passwordReset(res http.ResponseWriter, req *http.Request, vars map "Email": resetCnf.Email, } - if a.createAndSendNotification(resetCnf, emailContent) { + if a.createAndSendNotification(resetCnf, emailContent, reseterLanguage) { a.logMetricAsServer("reset confirmation sent") } else { a.logMetricAsServer("reset confirmation failed to be sent") diff --git a/api/hydrophoneApi.go b/api/hydrophoneApi.go index 2313af47d..11629d64b 100644 --- a/api/hydrophoneApi.go +++ b/api/hydrophoneApi.go @@ -10,6 +10,7 @@ import ( "strings" "github.com/gorilla/mux" + "github.com/nicksnyder/go-i18n/v2/i18n" commonClients "github.com/tidepool-org/go-common/clients" "github.com/tidepool-org/go-common/clients/highwater" @@ -21,19 +22,21 @@ import ( type ( Api struct { - Store clients.StoreClient - notifier clients.Notifier - templates models.Templates - sl shoreline.Client - gatekeeper commonClients.Gatekeeper - seagull commonClients.Seagull - metrics highwater.Client - Config Config + Store clients.StoreClient + notifier clients.Notifier + templates models.Templates + sl shoreline.Client + gatekeeper commonClients.Gatekeeper + seagull commonClients.Seagull + metrics highwater.Client + Config Config + LanguageBundle *i18n.Bundle } Config struct { - ServerSecret string `json:"serverSecret"` //used for services - WebURL string `json:"webUrl"` - AssetURL string `json:"assetUrl"` + ServerSecret string `json:"serverSecret"` //used for services + WebURL string `json:"webUrl"` + AssetURL string `json:"assetUrl"` + I18nTemplatesPath string `json:"i18nTemplatesPath"` } group struct { @@ -74,17 +77,45 @@ func InitApi( templates models.Templates, ) *Api { return &Api{ - Store: store, - Config: cfg, - notifier: ntf, - sl: sl, - gatekeeper: gatekeeper, - metrics: metrics, - seagull: seagull, - templates: templates, + Store: store, + Config: cfg, + notifier: ntf, + sl: sl, + gatekeeper: gatekeeper, + metrics: metrics, + seagull: seagull, + templates: templates, + LanguageBundle: nil, } } +// InitApiI18n initializes both the API and the i18n artefacts +func InitApiWithI18n( + cfg Config, + store clients.StoreClient, + ntf clients.Notifier, + sl shoreline.Client, + gatekeeper commonClients.Gatekeeper, + metrics highwater.Client, + seagull commonClients.Seagull, + templates models.Templates, +) *Api { + var theAPI *Api + theAPI = &Api{ + Store: store, + Config: cfg, + notifier: ntf, + sl: sl, + gatekeeper: gatekeeper, + metrics: metrics, + seagull: seagull, + templates: templates, + LanguageBundle: nil, + } + theAPI.InitI18n(cfg.I18nTemplatesPath) + return theAPI +} + func (a *Api) SetHandlers(prefix string, rtr *mux.Router) { rtr.HandleFunc("/status", a.GetStatus).Methods("GET") @@ -210,7 +241,11 @@ func (a *Api) checkFoundConfirmations(res http.ResponseWriter, results []*models } //Generate a notification from the given confirmation,write the error if it fails -func (a *Api) createAndSendNotification(conf *models.Confirmation, content map[string]interface{}) bool { +func (a *Api) createAndSendNotification(conf *models.Confirmation, content map[string]interface{}, lang string) bool { + + log.Printf("sending notification with template %s to %s with language %s", conf.TemplateName, conf.Email, lang) + + // Get the template name based on the requested communication type templateName := conf.TemplateName if templateName == models.TemplateNameUndefined { switch conf.Type { @@ -228,21 +263,32 @@ func (a *Api) createAndSendNotification(conf *models.Confirmation, content map[s } } + // Content collection is here to replace placeholders in template body/content content["WebURL"] = a.Config.WebURL content["AssetURL"] = a.Config.AssetURL + // Retrieve the template from all the preloaded templates template, ok := a.templates[templateName] if !ok { log.Printf("Unknown template type %s", templateName) return false } - subject, body, err := template.Execute(content) + // Add dynamic content to the template + fillTemplate(template, a.LanguageBundle, lang, content) + + // Email information (subject and body) are retrieved from the "executed" email template + // "Execution" adds dynamic content using text/template lib + _, body, err := template.Execute(content) + if err != nil { log.Printf("Error executing email template %s", err) return false } + // Get localized subject of email + subject, err := getLocalizedSubject(a.LanguageBundle, template.Subject(), lang) + // Finally send the email if status, details := a.notifier.Send([]string{conf.Email}, subject, body); status != http.StatusOK { log.Printf("Issue sending email: Status [%d] Message [%s]", status, details) return false @@ -253,6 +299,7 @@ func (a *Api) createAndSendNotification(conf *models.Confirmation, content map[s //find and validate the token func (a *Api) token(res http.ResponseWriter, req *http.Request) *shoreline.TokenData { if token := req.Header.Get(TP_SESSION_TOKEN); token != "" { + log.Printf("Found token in request header %s", token) td := a.sl.CheckToken(token) if td == nil { @@ -293,6 +340,7 @@ func (a *Api) findExistingUser(indentifier, token string) *shoreline.UserData { log.Printf("Error [%s] trying to get existing users details", err.Error()) return nil } else { + log.Printf("User found at shoreline using token %s", token) return usr } } diff --git a/api/internationalization.go b/api/internationalization.go new file mode 100644 index 000000000..69240b691 --- /dev/null +++ b/api/internationalization.go @@ -0,0 +1,183 @@ +package api + +import ( + "fmt" + "io/ioutil" + "log" + "net/http" + "strconv" + "strings" + + "github.com/nicksnyder/go-i18n/v2/i18n" + "github.com/tidepool-org/hydrophone/models" + "golang.org/x/text/language" + yaml "gopkg.in/yaml.v2" +) + +type LangQ struct { + Lang string + Q float64 +} + +const ( + HEADER_LANGUAGE = "Accept-Language" +) + +// InitI18n initializes the internationalization objects needed by the api +// Ensure at least en.yaml is present in the folder specified by TIDEPOOL_HYDROPHONE_SERVICE environment variable +func (a *Api) InitI18n(templatesPath string) { + + // Get all the language files that exist + langFiles, err := getAllLocalizationFiles(templatesPath) + + if err != nil { + log.Printf("Error getting translation files, %v", err) + } + + // Create a Bundle to use for the lifetime of your application + locBundle, err := createLocalizerBundle(langFiles) + + if err != nil { + log.Printf("Error initialising localization, %v", err) + } else { + log.Printf("Localizer bundle created with default language: %s", locBundle.DefaultLanguage.String()) + } + + a.LanguageBundle = locBundle +} + +// createLocalizerBundle reads language files and registers them in i18n bundle +func createLocalizerBundle(langFiles []string) (*i18n.Bundle, error) { + // Bundle stores a set of messages + bundle := &i18n.Bundle{DefaultLanguage: language.English} + + // Enable bundle to understand yaml + bundle.RegisterUnmarshalFunc("yaml", yaml.Unmarshal) + + var translations []byte + var err error + for _, file := range langFiles { + + // Read our language yaml file + translations, err = ioutil.ReadFile(file) + if err != nil { + fmt.Errorf("Unable to read translation file %s", file) + return nil, err + } + + // It parses the bytes in buffer to add translations to the bundle + bundle.MustParseMessageFileBytes(translations, file) + } + + return bundle, nil +} + +// getLocalizedContentPart returns translated content part based on key and locale +func getLocalizedContentPart(bundle *i18n.Bundle, key string, locale string, escape map[string]interface{}) (string, error) { + localizer := i18n.NewLocalizer(bundle, locale) + msg, err := localizer.Localize( + &i18n.LocalizeConfig{ + MessageID: key, + TemplateData: escape, + }, + ) + if msg == "" { + msg = "<< Cannot find translation for item " + key + " >>" + } + return msg, err +} + +// getLocalizedSubject returns translated subject based on key and locale +func getLocalizedSubject(bundle *i18n.Bundle, key string, locale string) (string, error) { + return getLocalizedContentPart(bundle, key, locale, nil) +} + +// fillTemplate fills the template content parts based on language bundle and locale +// A template content/body is made of HTML tags and content that can be localized +// Each template references its parts that can be filled in a collection called ContentParts +func fillTemplate(template models.Template, bundle *i18n.Bundle, locale string, content map[string]interface{}) { + // Get content parts from the template + for _, v := range template.ContentParts() { + // Each part is translated in the requested locale and added to the Content collection + contentItem, _ := getLocalizedContentPart(bundle, v, locale, fillEscapedParts(template, content)) + content[v] = contentItem + } +} + +// fillEscapedParts dynamically fills the escape parts with content +func fillEscapedParts(template models.Template, content map[string]interface{}) map[string]interface{} { + + // Escaped parts are replaced with content value + var escape = make(map[string]interface{}) + if template.EscapeParts() != nil { + for _, v := range template.EscapeParts() { + escape[v] = content[v] + } + } + + return escape +} + +// getAllLocalizationFiles returns all the filenames within the folder specified by the TIDEPOOL_HYDROPHONE_SERVICE environment variable +// Add yaml file to this folder to get a language added +// At least en.yaml should be present +func getAllLocalizationFiles(templatesPath string) ([]string, error) { + + var dir = templatesPath + "/locales/" + log.Printf("getting localization files from %s", dir) + var retFiles []string + files, err := ioutil.ReadDir(dir) + if err != nil { + log.Printf("Can't read directory %s", dir) + return nil, err + } + + for _, file := range files { + if !file.IsDir() && file.Name() != "test.en.yaml" { + log.Printf("Found localization file %s", dir+file.Name()) + retFiles = append(retFiles, dir+file.Name()) + } + } + return retFiles, nil +} + +//getBrowserPreferredLanguage returns the preferred language extracted from the request browser +func getBrowserPreferredLanguage(req *http.Request) string { + + if acptlng := req.Header.Get(HEADER_LANGUAGE); acptlng == "" { + return "" + } else if languages := parseAcceptLanguage(acptlng); languages == nil { + return "" + } else { + // if at least 1 lang is found, we return the 2 first characters of the first lang + // this header language item, although initially made for handling language is sometimes used to handle complete locale under form language-locale (eg FR-fr) + // hence we take only the 2 first characters + return languages[0].Lang[0:2] + } +} + +//parseAcceptLanguage will return array of languages extracted from given Accept-Language value +//Accept-Language value is retrieved from the Request Header +func parseAcceptLanguage(acptLang string) []LangQ { + var lqs []LangQ + + langQStrs := strings.Split(acptLang, ",") + for _, langQStr := range langQStrs { + trimedLangQStr := strings.Trim(langQStr, " ") + + langQ := strings.Split(trimedLangQStr, ";") + if len(langQ) == 1 { + lq := LangQ{langQ[0], 1} + lqs = append(lqs, lq) + } else { + qp := strings.Split(langQ[1], "=") + q, err := strconv.ParseFloat(qp[1], 64) + if err != nil { + panic(err) + } + lq := LangQ{langQ[0], q} + lqs = append(lqs, lq) + } + } + return lqs +} diff --git a/api/internationalization_test.go b/api/internationalization_test.go new file mode 100644 index 000000000..9c9baeb6b --- /dev/null +++ b/api/internationalization_test.go @@ -0,0 +1,89 @@ +package api + +import ( + "testing" + + "github.com/tidepool-org/hydrophone/models" + templates "github.com/tidepool-org/hydrophone/templates" +) + +const TestCreatorName = "Chuck Norris" + +const ( + expectedLocalizedContent = "This is a test content created by " + TestCreatorName + "." + expectedSubject = "This email is here for testing purposes." // The subject we expect to find after compilation and localization + expectedBody = "
Test Template. Please keep this file in this folder.
This is a test content created by " + TestCreatorName + "." // The HTML body we expect to find after compilation and localization + locale = "en" + templatesPath = "../templates" + langFile = "../templates/locales/test.en.yaml" +) + +func Test_CreateLocalizerBundle(t *testing.T) { + + // Create a Bundle to use for the lifetime of your application + locBundle, err := createLocalizerBundle(nil) + + if locBundle == nil { + t.Fatalf("Failed to create bundle: %s", err.Error()) + } +} + +func Test_GetLocalizedPart(t *testing.T) { + var langFiles []string + langFiles = append(langFiles, langFile) + + // Create a Bundle to use for the lifetime of your application + locBundle, err := createLocalizerBundle(langFiles) + + if locBundle == nil { + t.Fatalf("Failed to create bundle: %s", err.Error()) + } + + // For each content that needs to be filled, add localized content to a temp variable "content" + content := make(map[string]interface{}) + content["TestCreatorName"] = TestCreatorName + localizedContent, _ := getLocalizedContentPart(locBundle, "TestContentInjection", locale, content) + + if localizedContent != expectedLocalizedContent { + t.Fatalf("Wrong localized content, expecting %s but found %s", expectedLocalizedContent, localizedContent) + } +} + +func Test_ExecuteTemplate(t *testing.T) { + + var langFiles []string + langFiles = append(langFiles, langFile) + + // Create a Bundle to use for the lifetime of your application + locBundle, err := createLocalizerBundle(langFiles) + if locBundle == nil { + t.Fatalf("Failed to create bundle: %s", err.Error()) + } + + // Create Test Template + temp, err := templates.NewTemplate(templatesPath, models.TemplateNameTest) + + if temp == nil { + t.Fatalf("Failed to create test template: %s", err.Error()) + } + + // For each content that needs to be filled, add localized content to a temp variable "content" + content := make(map[string]interface{}) + content["TestCreatorName"] = TestCreatorName + fillTemplate(temp, locBundle, locale, content) + + // Execute the template with provided content + _, body, err := temp.Execute(content) + // Get localized subject of email + subject, err := getLocalizedSubject(locBundle, temp.Subject(), locale) + + // Check subject + if subject != expectedSubject { + t.Fatalf("Expecting subject %s but executed %s", expectedSubject, subject) + } + + // Check Body + if body != expectedBody { + t.Fatalf("Compiled body is not the one expected (see below): \n %s \n %s", body, string(expectedBody)) + } +} diff --git a/api/invite.go b/api/invite.go index 69ac62805..9bec842d9 100644 --- a/api/invite.go +++ b/api/invite.go @@ -361,6 +361,9 @@ func (a *Api) DismissInvite(res http.ResponseWriter, req *http.Request, vars map // status: 409 statusExistingMemberMessage - user is already part of the team // status: 400 func (a *Api) SendInvite(res http.ResponseWriter, req *http.Request, vars map[string]string) { + // By default, the invitee language will be "en" for Englih (as we don't know which language suits him) + // In case the invitee is a known user, the language will be overriden in a later step + var inviteeLanguage = "en" if token := a.token(res, req); token != nil { invitorID := vars["userid"] @@ -399,9 +402,21 @@ func (a *Api) SendInvite(res http.ResponseWriter, req *http.Request, vars map[st //None exist so lets create the invite invite, _ := models.NewConfirmationWithContext(models.TypeCareteamInvite, models.TemplateNameCareteamInvite, invitorID, ib.Permissions) + // if the invitee is already a Tidepool user, we can use his preferences invite.Email = ib.Email if invitedUsr != nil { invite.UserId = invitedUsr.UserID + + // let's get the invitee user preferences + inviteePreferences := &models.Preferences{} + if err := a.seagull.GetCollection(invite.UserId, "preferences", a.sl.TokenProvide(), inviteePreferences); err != nil { + a.sendError(res, http.StatusInternalServerError, STATUS_ERR_FINDING_USR, "send invitation: error getting invitee user preferences: ", err.Error()) + return + } + // does the invitee have a preferred language? + if inviteePreferences.DisplayLanguage != "" { + inviteeLanguage = inviteePreferences.DisplayLanguage + } } if a.addOrUpdateConfirmation(invite, res) { @@ -419,6 +434,7 @@ func (a *Api) SendInvite(res http.ResponseWriter, req *http.Request, vars map[st var webPath = "signup" + // if invitee is already a user (ie already has an account), he won't go to signup but login instead if invite.UserId != "" { webPath = "login" } @@ -429,7 +445,7 @@ func (a *Api) SendInvite(res http.ResponseWriter, req *http.Request, vars map[st "WebPath": webPath, } - if a.createAndSendNotification(invite, emailContent) { + if a.createAndSendNotification(invite, emailContent, inviteeLanguage) { a.logMetric("invite sent", req) } } diff --git a/api/signup.go b/api/signup.go index e084c570c..277f36666 100644 --- a/api/signup.go +++ b/api/signup.go @@ -105,6 +105,7 @@ func (a *Api) updateSignupConfirmation(newStatus models.Status, res http.Respons // status: 403 STATUS_EXISTING_SIGNUP // status: 500 STATUS_ERR_FINDING_USER func (a *Api) sendSignUp(res http.ResponseWriter, req *http.Request, vars map[string]string) { + var signerLanguage string if token := a.token(res, req); token != nil { userId := vars["userid"] if userId == "" { @@ -211,7 +212,14 @@ func (a *Api) sendSignUp(res http.ResponseWriter, req *http.Request, vars map[st emailContent["CreatorName"] = newSignUp.Creator.Profile.FullName } - if a.createAndSendNotification(newSignUp, emailContent) { + // although technically there exists a profile at the signup stage, the preferred language would always be empty here + // as it is set in the app and once the signup procedure is complete (after signup email has been confirmed) + // -> get browser's or "en" for English in case there is no browser's + if signerLanguage = getBrowserPreferredLanguage(req); signerLanguage == "" { + signerLanguage = "en" + } + + if a.createAndSendNotification(newSignUp, emailContent, signerLanguage) { a.logMetricAsServer("signup confirmation sent") res.WriteHeader(http.StatusOK) return @@ -232,6 +240,7 @@ func (a *Api) sendSignUp(res http.ResponseWriter, req *http.Request, vars map[st // status: 404 STATUS_SIGNUP_EXPIRED func (a *Api) resendSignUp(res http.ResponseWriter, req *http.Request, vars map[string]string) { + var signerLanguage string email := vars["useremail"] toFind := &models.Confirmation{Email: email, Status: models.StatusPending} @@ -275,7 +284,14 @@ func (a *Api) resendSignUp(res http.ResponseWriter, req *http.Request, vars map[ emailContent["CreatorName"] = found.Creator.Profile.FullName } - if a.createAndSendNotification(found, emailContent) { + // although technically there exists a profile at the signup stage, the preferred language would always be empty here + // as it is set in the app and once the signup procedure is complete (after signup email has been confirmed) + // -> get browser's or "en" for English in case there is no browser's + if signerLanguage = getBrowserPreferredLanguage(req); signerLanguage == "" { + signerLanguage = "en" + } + + if a.createAndSendNotification(found, emailContent, signerLanguage) { a.logMetricAsServer("signup confirmation re-sent") } else { a.logMetricAsServer("signup confirmation failed to be sent") diff --git a/build.sh b/build.sh index 41cdefdc5..bce6be197 100755 --- a/build.sh +++ b/build.sh @@ -2,6 +2,8 @@ rm -rf dist mkdir dist +go get gopkg.in/mgo.v2 github.com/nicksnyder/go-i18n/v2/i18n gopkg.in/yaml.v2 go build -o dist/hydrophone hydrophone.go cp env.sh dist/ cp start.sh dist/ +rsync -av --progress templates dist/ --exclude '*.go' \ No newline at end of file diff --git a/docs/README.md b/docs/README.md index 9c7aa6883..8a4b21727 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,11 +1,51 @@ Hydrophone Dev Docs === -## Email Template Generation +# HTML files templates and Internationalization -### Current Practice +## Notice -When updating the email templates within the root `templates` folder, it's important not to simply make inline changes to the templates there. +Internationalization of emails has been introduced in Hydrophone through the use of static HTML files that contain placeholders for localization content to be filled at runtime. +This internationalization is based on the audience language. The audience language following a logic based on Tidepool user language, browser language and English as a default. + +As a matter of fact, the previous logic of having in-code templates (ie in .go files) for emails has been moved to a logic of having templates generated from static files residing on the file system. A potential evolution can be to have files hosted on a S3 bucket (after pitfall described below is solved). + +The framework needs a specific folder to be on the filesystem and referenced by the environment variable `TIDEPOOL_HYDROPHONE_SERVICE` (_internationalizationTemplatesPath_). This folder contains the following subfolders: +* html: html template files. They are the final ones, with CSS inlined +* locales: content in various languages. One file per language that name is under format {language_ISO2}.yml +* meta: emails structure files +* source: all the HTML artefacts (html, csss, img) to build the final html templates (process of inlining) + +## Meta files + +Each HTML file has its corresponding meta file. This meta file describes the template structure. One should ensure meta file contains: +- templateFileName: the name of the html file that this meta is linked to (this is the actual way of linking HTML and meta) +- subject: the name of the key for the email subject that has its corresponding values translated in the locale files +- contentParts: an array of all the keys that can be localized. The keys name the placeholders found in the html files under form {{.keyName}} +- escapeParts: an array of key names that will be escaped during localizations. There will be no tentative to replace these keys by a localized value. It will then not be taken by the translation engine. This key will instead be replaced by information given programmatically. A good example is if you want to include the name of the user in the middle of a localizable text. Note: these keys cannot be changed without a code change. + +## Pitfall + + Following the previous logic of having all the templates in memory when the service is starting, this first version of emails based on HTML templates has the same pitfall. It needs a service restart to take changes in the HTML files into consideration. + +One part of the path to have a more dynamic behaviour is already crossed with the use of the meta files. These meta files ensure: +- we can add more content to the html file without code change +- we can change the names of the HTML files without code change +The current limit, besides the pitfall of having to restart service when template is amended, is when a new type of template is needed (other than existing "signup", "password forget", ...): this would require code change. + +## Possible Enhancements + +The current logic is to have all the templates loaded at the initialization of the API service. This makes the dynamic behaviour of using static files very limited. Review this logic to have static files be monitored and reloaded whenever a change appears. + +Have the templates files hosted in an external repository (eg AWS S3) for ease of changes for non-technical teams. + +It is needed to resolve the pitfall above before adding this feature. + +# Email Template Generation + +## Current Practice + +The process of updating the HTML templates is NOT to amend the files within the `templates/html` folder. The process is to amend the HTML files in the `templates/source` folder along with the css, then inline the CSS in all the HTML files. For the purpose of ease of development and ongoing template maintainance, we develop these templates with the more common, web-friendly approach of using an external stylesheet and keeping our markup clean. @@ -13,21 +53,24 @@ We then use a tool that _inlines_ the CSS for us in a way that's appropriate for The goal here is to ensure that we keep the many email templates consistent with each other as far as styling goes, and keep our HTML markup clean. -#### Developing with Source Files +## Developing with Source Files -For now, all the source files for development are in the `templates-source` folder sitting alongside this doc. +For now, all the source files for development are in the `templates/source` folder. You can serve these files however you like for local development. One simple solution is to, from the terminal in the `templates-source` directory, run python's SimpleHTTPServer like this: ```shell +# Python 2 python -m SimpleHTTPServer 8000 +# Python 3 +python -m http.server 8000 ``` At this point, you should be able to view the email in your browser at, for instance, `http://localhost:8000/signup.html`. -We also have an `index.html` file set up with links to all the templates. +We also have an `index.html` file set up with links to all the templates, `http://localhost:8000/index.html`. -#### Assets (Images) file locations +## Assets (Images) file locations All the email assets must be stored in a publicly accessible location. We use Amazon S3 buckets for this. Assets are stored per environment, so we can have different assets on `dev`, `stg`, `int`, and `prd` @@ -43,7 +86,7 @@ Currently, only the backend engineering team has access to these buckets, so all During development, you should change the image sources to use files in the local `img` folder. This way, you won't need to ask to have the files uploaded to S3 until you're sure they're ready for QA. This is also helpful, as it keeps a record of intended file changes in version control. -#### Inlining the CSS +## Inlining the CSS Until we implement the [Recommended Future Improvements](#recommended-future-improvements) detailed later in this doc, inlining the CSS will be a manual process. We currently use the online [PutsMail CSS Inliner](https://www.putsmail.com/inliner) tool made by the email testing company, Litmus. @@ -76,19 +119,8 @@ becomes ```html ``` -#### Local Email Testing - -Testing locally requires that you have a temporary AWS SES credentials provide to you by the backend engineering team lead. These credentials must be kept private, as soon as testing is complete, the engineering team lead mush be informed so as to revoke them. - -Extreme care must be taken to not commit this to out public git repo. If that were to happen, for any reason or lenght of time, the backend engineering team lead MUST be notified immediately. -#### Multiple Email Client Testing - -It's important to test the final email rendering in as many email clients as possible. Emails are notorioulsy fickle, and using a testing service such as Litmus or Email on Acid is recommended before going to production with any markup/styling changes. - -We currently haven't settled on which of these 2 services to set up an account with. We've tried both. Email on Acid is about half the price, and suits our needs well enough, so we will likely go that route. Litmus, however, is nicer for it's in-place editing to iron out the many difficult issues in Outlook (or really any of the MS mail clients). - -#### Final Post-Inlining Steps +## Final Post-Inlining Steps Once our CSS is inlined properly, there are a couple of things we need to do before pasting the resulting code into the corresponding Go templates. @@ -107,7 +139,21 @@ with ``` -### Recommended Future Improvements +# Testing + +## Local Email Testing + +Testing locally requires that you have a temporary AWS SES credentials provide to you by the backend engineering team lead. These credentials must be kept private, as soon as testing is complete, the engineering team lead mush be informed so as to revoke them. + +Extreme care must be taken to not commit this to out public git repo. If that were to happen, for any reason or lenght of time, the backend engineering team lead MUST be notified immediately. + +## Multiple Email Client Testing + +It's important to test the final email rendering in as many email clients as possible. Emails are notorioulsy fickle, and using a testing service such as Litmus or Email on Acid is recommended before going to production with any markup/styling changes. + +We currently haven't settled on which of these 2 services to set up an account with. We've tried both. Email on Acid is about half the price, and suits our needs well enough, so we will likely go that route. Litmus, however, is nicer for it's in-place editing to iron out the many difficult issues in Outlook (or really any of the MS mail clients). + +# Recommended Future Improvements For now, what we're doing is better than in-place editing of the templates for the reasons noted above. There are, however, many ways this process could be improved in the future. @@ -115,4 +161,5 @@ The most notable candidate for improvement is to perform the CSS _inlining_ with Another would be to share all of the common markup in HTML templates, and piece them together at build time. Again, Gulp could be used for this, and would be rather quick to implement. There is a good writeup [here](https://bitsofco.de/a-gulp-workflow-for-building-html-email/) on one possible approach using gulp. There is even a [github repo](https://github.com/ireade/gulp-email-workflow/tree/master/src/templates) from this example that is meant as a starting point, so we could basically plug our styles and templates in to it and it should be done at that point. -This process would also take care of all of the other small manual final prepartation steps outlined in our current process above. +This process would also take care of all of the other small manual final preparation steps outlined in our current process above. + diff --git a/docs/templates-source/index.html b/docs/templates-source/index.html deleted file mode 100644 index 2822abe95..000000000 --- a/docs/templates-source/index.html +++ /dev/null @@ -1,14 +0,0 @@ - - - Email templates - - - - - diff --git a/env.sh b/env.sh index f3fa8d5cc..5388090a0 100644 --- a/env.sh +++ b/env.sh @@ -30,7 +30,8 @@ export TIDEPOOL_HYDROPHONE_SERVICE='{ "hydrophone" : { "serverSecret": "This needs to be the same secret everywhere. YaHut75NsK1f9UKUXuWqxNN0RUwHFBCy", "webUrl": "http://localhost:3000", - "assetUrl": "https://s3-us-west-2.amazonaws.com/tidepool-dev-asset" + "assetUrl": "https://s3-us-west-2.amazonaws.com/tidepool-dev-asset", + "i18nTemplatesPath": "/go/src/github.com/tidepool-org/hydrophone/templates" }, "sesEmail" : { "serverEndpoint":"https://email.us-west-2.amazonaws.com", diff --git a/hydrophone.go b/hydrophone.go index bb9f2ed0f..2e847c5f1 100644 --- a/hydrophone.go +++ b/hydrophone.go @@ -35,6 +35,7 @@ type ( func main() { var config Config + // Load configuration from environment variables if err := common.LoadEnvironmentConfig([]string{"TIDEPOOL_HYDROPHONE_ENV", "TIDEPOOL_HYDROPHONE_SERVICE"}, &config); err != nil { log.Panic("Problem loading config ", err) } @@ -71,6 +72,8 @@ func main() { log.Fatal(err) } + log.Printf("Shoreline client started with server token %s", shoreline.TokenProvide()) + gatekeeper := clients.NewGatekeeperClientBuilder(). WithHostGetter(config.GatekeeperConfig.ToHostGetter(hakkenClient)). WithHttpClient(httpClient). @@ -94,13 +97,16 @@ func main() { store := sc.NewMongoStoreClient(&config.Mongo) mail := sc.NewSesNotifier(&config.Mail) - emailTemplates, err := templates.New() + // Create collection of pre-compiled templates + // Templates are built based on HTML files which location is calculated from config + // Config is initalized with environment variables + emailTemplates, err := templates.New(config.Api.I18nTemplatesPath) if err != nil { log.Fatal(err) } rtr := mux.NewRouter() - api := api.InitApi(config.Api, store, mail, shoreline, gatekeeper, highwater, seagull, emailTemplates) + api := api.InitApiWithI18n(config.Api, store, mail, shoreline, gatekeeper, highwater, seagull, emailTemplates) api.SetHandlers("", rtr) /* diff --git a/models/confirmation.go b/models/confirmation.go index b401c37f8..37d970734 100644 --- a/models/confirmation.go +++ b/models/confirmation.go @@ -36,6 +36,9 @@ type ( IsOtherPerson bool `json:"isOtherPerson"` FullName string `json:"fullName"` } + Preferences struct { + DisplayLanguage string `json:"displayLanguageCode"` + } Profile struct { FullName string `json:"fullName"` Patient Patient `json:"patient"` diff --git a/models/confirmation_test.go b/models/confirmation_test.go index f2a8e3102..81acf7a16 100644 --- a/models/confirmation_test.go +++ b/models/confirmation_test.go @@ -50,7 +50,7 @@ func Test_NewConfirmation(t *testing.T) { } if confirmation.Creator.Profile != nil { - t.Logf("expected `nil` actual [%s]", confirmation.Creator.Profile) + t.Logf("expected `nil` actual [%s]", confirmation.Creator.Profile.FullName) t.Fail() } diff --git a/models/template.go b/models/template.go index b03af0c04..2185b9150 100644 --- a/models/template.go +++ b/models/template.go @@ -22,12 +22,16 @@ const ( TemplateNameSignupClinic TemplateName = "signup_clinic_confirmation" TemplateNameSignupCustodial TemplateName = "signup_custodial_confirmation" TemplateNameSignupCustodialClinic TemplateName = "signup_custodial_clinic_confirmation" + TemplateNameTest TemplateName = "test_template" TemplateNameUndefined TemplateName = "" ) type Template interface { Name() TemplateName Execute(content interface{}) (string, string, error) + ContentParts() []string + EscapeParts() []string + Subject() string } type Templates map[TemplateName]Template @@ -36,9 +40,13 @@ type PrecompiledTemplate struct { name TemplateName precompiledSubject *template.Template precompiledBody *template.Template + contentParts []string + subject string + escapeParts []string } -func NewPrecompiledTemplate(name TemplateName, subjectTemplate string, bodyTemplate string) (*PrecompiledTemplate, error) { +// NewPrecompiledTemplate creates a new pre-compiled template +func NewPrecompiledTemplate(name TemplateName, subjectTemplate string, bodyTemplate string, contentParts []string, escapeParts []string) (*PrecompiledTemplate, error) { if name == TemplateNameUndefined { return nil, errors.New("models: name is missing") } @@ -63,14 +71,37 @@ func NewPrecompiledTemplate(name TemplateName, subjectTemplate string, bodyTempl name: name, precompiledSubject: precompiledSubject, precompiledBody: precompiledBody, + subject: subjectTemplate, + contentParts: contentParts, + escapeParts: escapeParts, }, nil } +// Name of the template func (p *PrecompiledTemplate) Name() TemplateName { return p.name } +// Subject of the template +func (p *PrecompiledTemplate) Subject() string { + return p.subject +} + +// ContentParts returns the content parts of the template +// Content parts are the items that are dynamically localized and added in the html tags +func (p *PrecompiledTemplate) ContentParts() []string { + return p.contentParts +} + +// EscapeParts returns the escape parts of the template +// These parts are those that are not translated with go-i18n but need to be replaced dynamically by Tidepool engine +func (p *PrecompiledTemplate) EscapeParts() []string { + return p.escapeParts +} + +// Execute compiles the pre-compiled template with provided content func (p *PrecompiledTemplate) Execute(content interface{}) (string, string, error) { + var subjectBuffer bytes.Buffer var bodyBuffer bytes.Buffer diff --git a/models/template_test.go b/models/template_test.go index 7c917d277..9f4e1d8eb 100644 --- a/models/template_test.go +++ b/models/template_test.go @@ -23,7 +23,7 @@ var ( func Test_NewPrecompiledTemplate_NameMissing(t *testing.T) { expectedError := "models: name is missing" - tmpl, err := NewPrecompiledTemplate("", subjectSuccessTemplate, bodySuccessTemplate) + tmpl, err := NewPrecompiledTemplate("", subjectSuccessTemplate, bodySuccessTemplate, nil, nil) if err == nil || err.Error() != expectedError { t.Fatalf(`Error is "%s", but should be "%s"`, err, expectedError) } @@ -34,7 +34,7 @@ func Test_NewPrecompiledTemplate_NameMissing(t *testing.T) { func Test_NewPrecompiledTemplate_SubjectTemplateMissing(t *testing.T) { expectedError := "models: subject template is missing" - tmpl, err := NewPrecompiledTemplate(name, "", bodySuccessTemplate) + tmpl, err := NewPrecompiledTemplate(name, "", bodySuccessTemplate, nil, nil) if err == nil || err.Error() != expectedError { t.Fatalf(`Error is "%s", but should be "%s"`, err, expectedError) } @@ -45,7 +45,7 @@ func Test_NewPrecompiledTemplate_SubjectTemplateMissing(t *testing.T) { func Test_NewPrecompiledTemplate_BodyTemplateMissing(t *testing.T) { expectedError := "models: body template is missing" - tmpl, err := NewPrecompiledTemplate(name, subjectSuccessTemplate, "") + tmpl, err := NewPrecompiledTemplate(name, subjectSuccessTemplate, "", nil, nil) if err == nil || err.Error() != expectedError { t.Fatalf(`Error is "%s", but should be "%s"`, err, expectedError) } @@ -56,7 +56,7 @@ func Test_NewPrecompiledTemplate_BodyTemplateMissing(t *testing.T) { func Test_NewPrecompiledTemplate_SubjectTemplateNotPrecompiled(t *testing.T) { expectedError := "models: failure to precompile subject template: template: test:1: unexpected EOF" - tmpl, err := NewPrecompiledTemplate(name, subjectFailureTemplate, bodySuccessTemplate) + tmpl, err := NewPrecompiledTemplate(name, subjectFailureTemplate, bodySuccessTemplate, nil, nil) if err == nil || err.Error() != expectedError { t.Fatalf(`Error is "%s", but should be "%s"`, err, expectedError) } @@ -67,7 +67,7 @@ func Test_NewPrecompiledTemplate_SubjectTemplateNotPrecompiled(t *testing.T) { func Test_NewPrecompiledTemplate_BodyTemplateNotPrecompiled(t *testing.T) { expectedError := "models: failure to precompile body template: template: test:1: unexpected EOF" - tmpl, err := NewPrecompiledTemplate(name, subjectSuccessTemplate, bodyFailureTemplate) + tmpl, err := NewPrecompiledTemplate(name, subjectSuccessTemplate, bodyFailureTemplate, nil, nil) if err == nil || err.Error() != expectedError { t.Fatalf(`Error is "%s", but should be "%s"`, err, expectedError) } @@ -77,7 +77,7 @@ func Test_NewPrecompiledTemplate_BodyTemplateNotPrecompiled(t *testing.T) { } func Test_NewPrecompiledTemplate_Success(t *testing.T) { - tmpl, err := NewPrecompiledTemplate(name, subjectSuccessTemplate, bodySuccessTemplate) + tmpl, err := NewPrecompiledTemplate(name, subjectSuccessTemplate, bodySuccessTemplate, nil, nil) if err != nil { t.Fatalf(`Error is "%s", but should be nil`, err) } @@ -87,7 +87,7 @@ func Test_NewPrecompiledTemplate_Success(t *testing.T) { } func Test_NewPrecompiledTemplate_Name(t *testing.T) { - tmpl, _ := NewPrecompiledTemplate(name, subjectSuccessTemplate, bodySuccessTemplate) + tmpl, _ := NewPrecompiledTemplate(name, subjectSuccessTemplate, bodySuccessTemplate, nil, nil) if tmpl.Name() != name { t.Fatalf(`Name is "%s", but should be "%s"`, tmpl.Name(), name) } @@ -96,7 +96,7 @@ func Test_NewPrecompiledTemplate_Name(t *testing.T) { func Test_NewPrecompiledTemplate_ExecuteSuccess(t *testing.T) { expectedSubject := `Username is 'Test User'` expectedBody := `Key is '123.blah.456.blah'` - tmpl, _ := NewPrecompiledTemplate(name, subjectSuccessTemplate, bodySuccessTemplate) + tmpl, _ := NewPrecompiledTemplate(name, subjectSuccessTemplate, bodySuccessTemplate, nil, nil) subject, body, err := tmpl.Execute(content) if err != nil { t.Fatalf(`Error is "%s", but should be nil`, err) diff --git a/templates/careteam_invite.go b/templates/html/careteam_invitation.html similarity index 90% rename from templates/careteam_invite.go rename to templates/html/careteam_invitation.html index c8926a465..4d489eb2c 100644 --- a/templates/careteam_invite.go +++ b/templates/html/careteam_invitation.html @@ -1,9 +1,3 @@ -package templates - -import "github.com/tidepool-org/hydrophone/models" - -const _CareteamInviteSubjectTemplate string = `Diabetes care team invitation` -const _CareteamInviteBodyTemplate string = ` @@ -43,10 +37,10 @@

- Hey there! + {{.CareTeamInviteHello}}

- {{ .CareteamName }} invited you to be on their care team.

Please click the link below to accept and see {{ .CareteamName }}’s data. + {{ .CareTeamInviteInvitation }}

{{.CareTeamInviteClick}}

@@ -58,7 +52,7 @@ - Join {{ .CareteamName }}'s Care Team + {{.CareTeamInviteJoin}} - Get Support + {{.FooterGetSupport}} - Sign Up + {{.NoAccountSignUp}} - Get Support + {{.FooterGetSupport}} - Reset Password + {{ .PasswordResetClick }} - Get Support + {{.FooterGetSupport}} - Verify Your Account + {{.SignupClinicVerify}} - Get Support + {{.FooterGetSupport}} - Claim Your Account + {{.SignupVerify}} - Get Support + {{.FooterGetSupport}} + + + + + + + + +
+
+ + + + + +
+ + + + + + + + + + + + + + + + + + + + + + +
+

+ {{.SignupCustodialClinicHello}} +

+

+ {{.SignupCustodialClinicAccount}}

{{.SignupCustodialClinicOwnership}} +

+
+ + + {{.SignupCustodialClinicClaim}} + + +
+

{{.FooterSincerely}}
{{.FooterSignature}}

+
+ +
+ + + + + + + + +
+

+ Tidepool + {{.FooterOpenSource}} +

+
+ + + + + + + +
+
+ +
+
+ + \ No newline at end of file diff --git a/templates/html/signup_custodial_confirmation.html b/templates/html/signup_custodial_confirmation.html new file mode 100644 index 000000000..6f00d73d0 --- /dev/null +++ b/templates/html/signup_custodial_confirmation.html @@ -0,0 +1,153 @@ + + + + + + + + + + + + + +
+
+ + + + + +
+ + + + + + + + + + + + + + + + + + + + + + +
+

+ {{.SignupCustodialHello}} +

+

+ {{.SignupCustodialAccount}}

{{.SignupCustodialOwnership}} +

+
+ + + {{.SignupCustodialClaim}} + + +
+

{{.FooterSincerely}}
{{.FooterSignature}}

+
+ +
+ + + + + + + + +
+

+ Tidepool + {{.FooterOpenSource}} +

+
+ + + + + + + +
+
+ +
+
+ + \ No newline at end of file diff --git a/templates/html/test_template.html b/templates/html/test_template.html new file mode 100644 index 000000000..3349e0afb --- /dev/null +++ b/templates/html/test_template.html @@ -0,0 +1 @@ +
Test Template. Please keep this file in this folder.
{{ .TestContentInjection }} \ No newline at end of file diff --git a/templates/locales/en.yaml b/templates/locales/en.yaml new file mode 100644 index 000000000..fc742361d --- /dev/null +++ b/templates/locales/en.yaml @@ -0,0 +1,61 @@ +# Footer +# (for all emails) +FooterOpenSource: "An open source, not-for-profit effort to build an open data platform and better applications that reduce the burden of diabetes." +FooterGetSupport: "Get Support" +FooterSincerely: "Sincerely," +FooterSignature: "The Tidepool Team" +FooterMadePossible: "Made possible by" + +# No account +# (email for those that requested password reset but did not create their account before) +NoAccountSubject: "Password reset for your Tidepool account" +NoAccountHello: "Hey there!" +NoAccountNoAccount: "We heard you would like to reset your Tidepool password but no account has been created yet for your email address." +NoAccountClick: "Please click on the link below if you would like to create an account." +NoAccountSignUp: "Sign Up" + +# Password Reset +# (email for those that requested password reset) +PasswordResetSubject: "Password reset for your Tidepool account" +PasswordResetHello: "Hey there!" +PasswordResetRequest: "You requested a password reset." +PasswordResetDidNotRequest: "If you didn't request this, please ignore this email." +PasswordResetOtherwise: "Otherwise, click the link below." +PasswordResetClick: "Reset Password" + +# CareTeamInvite +# (email for those that invited someone to join medical crew) +CareTeamInvitationSubject: "Diabetes care team invitation" +CareTeamInviteHello: "Hey there!" +CareTeamInviteInvitation: "{{ .CareteamName }} invited you to be on their care team." +CareTeamInviteClick: "Please click the link below to accept and see {{ .CareteamName }}'s data." +CareTeamInviteJoin: "Join {{ .CareteamName }}'s Care Team" + +# Signup +# (email to ask patient for verification of email) +SignupConfirmationSubject: "Verify your Tidepool account" +SignupHello: "Hey there!" +SignupCongrats: "Congrats on creating your Tidepool account!" +SignupVerify: "Verify Your Account" + +# Signup Clinic +# (email to ask clinician for verification of email) +SignupClinicConfirmationSubject: "Verify your Tidepool account" +SignupClinicHello: "Hey there!" +SignupClinicCongrats: "Congrats on creating your Tidepool account!" +SignupClinicVerify: "Verify Your Account" + +# Signup Custodial +# (email to ask patient spawn by an existing clinician for verification of email and take ownership of his account) +SignupCustodialConfirmationSubject: "Verify your YourLoops account" +SignupCustodialHello: "Hello," +SignupCustodialCongrats: "Congrats on creating your YourLoops account!" +SignupCustodialVerify: "Verify Your Account" + +# Signup Custodial Clinic +# (email to ask clinician spawn by an existing clinician for verification of email and take ownership of his account) +SignupCustodialClinicConfirmationSubject: "Diabetes Clinic Follow Up - Claim Your Account" +SignupCustodialClinicHello: "Hi, {{ .FullName }}!" +SignupCustodialClinicAccount: "{{ .CreatorName }} created a Tidepool account for your diabetes device data." +SignupCustodialClinicOwnership: "You can take ownership of your free account to view and upload data from home." +SignupCustodialClinicClaim: "Claim Your Account" \ No newline at end of file diff --git a/templates/locales/fr.yaml b/templates/locales/fr.yaml new file mode 100644 index 000000000..0c1972016 --- /dev/null +++ b/templates/locales/fr.yaml @@ -0,0 +1,61 @@ +# Footer +# (for all emails) +FooterOpenSource: "Un projet open source, non profitable, pour construire une plateforme et des applications qui réduisent le fardeau du diabète." +FooterGetSupport: "Contacter le support" +FooterSincerely: "Cordialement," +FooterSignature: "L'équipe Tidepool" +FooterMadePossible: "Rendu possible grâce à" + +# No account +# (email for those that requested password reset but did not create their account before) +NoAccountSubject: "Réinitialisation de votre mot de passe Tidepool" +NoAccountHello: "Bonjour," +NoAccountNoAccount: "Nous nous sommes laissés dire que vous vouliez réinitialiser votre mot de passe Tidepool. Mais il n'existe pas de compte pour cette adresse email." +NoAccountClick: "Veuillez cliquer sur le lien ci-dessous si vous voulez créer un compte." +NoAccountSignUp: "M'enregistrer" + +# Password Reset +# (email for those that requested password reset) +PasswordResetSubject: "Réinitialisation de votre mot de passe Tidepool" +PasswordResetHello: "Bonjour," +PasswordResetRequest: "Vous avez demandé une réinitialisation de votre mot de passe Tidepool." +PasswordResetDidNotRequest: "Si vous n'êtes pas à l'origine de cette demande, merci d'ignorer cet email." +PasswordResetOtherwise: "Sinon, veuillez cliquer sur le lien ci-dessous." +PasswordResetClick: "Réinitialiser mon mot de passe" + +# CareTeamInvite +# (email for those that invited someone to join medical crew) +CareTeamInvitationSubject: "Invitation à rejoindre une équipe de suivi d'un diabétique" +CareTeamInviteHello: "Bonjour," +CareTeamInviteInvitation: "{{ .CareteamName }} vous a invité à rejoindre son équipe de suivi." +CareTeamInviteClick: "Veuillez cliquer sur le lien ci-dessous pour accepter et voir les données de {{ .CareteamName }}." +CareTeamInviteJoin: "Réjoindre l'équipe de {{ .CareteamName }}" + +# Signup +# (email to ask patient for verification of email) +SignupConfirmationSubject: "Vérification de votre compte Tidepool" +SignupHello: "Bonjour," +SignupCongrats: "Félicitations pour la création de votre compte Tidepool !" +SignupVerify: "Vérifier mon compte" + +# Signup Clinic +# (email to ask clinician for verification of email) +SignupClinicConfirmationSubject: "Vérification de votre compte Tidepool" +SignupClinicHello: "Bonjour," +SignupClinicCongrats: "Félicitations pour la création de votre compte Tidepool !" +SignupClinicVerify: "Vérifier mon compte" + +# Signup Custodial +# (email to ask patient spawn by an existing clinician for verification of email and take ownership of his account) +SignupCustodialConfirmationSubject: "Vérification de votre compte Tidepool" +SignupCustodialHello: "Bonjour," +SignupCustodialCongrats: "Félicitations pour la création de votre compte Tidepool !" +SignupCustodialVerify: "Vérifier mon compte" + +# Signup Custodial Clinic +# (email to ask clinician spawn by an existing clinician for verification of email and take ownership of his account) +SignupCustodialClinicConfirmationSubject: "Suivi Diabète - Réclamer votre compte" +SignupCustodialClinicHello: "Bonjour {{ .FullName }} !" +SignupCustodialClinicAccount: "{{ .CreatorName }} a créé pour vous un compte Tidepool pour que vous puissiez visualiser vos données de diabète." +SignupCustodialClinicOwnership: "Vous pouvez réclamer votre compte gratuit pour voir et charger vos données depuis la maison." +SignupCustodialClinicClaim: "Réclamer mon compte" \ No newline at end of file diff --git a/templates/locales/test.en.yaml b/templates/locales/test.en.yaml new file mode 100644 index 000000000..39131b078 --- /dev/null +++ b/templates/locales/test.en.yaml @@ -0,0 +1,4 @@ +# Test localization content +# Please, keep this file in this folder +TestTemplateSubject: "This email is here for testing purposes." +TestContentInjection: "This is a test content created by {{ .TestCreatorName }}." \ No newline at end of file diff --git a/templates/meta/careteam_invitation.json b/templates/meta/careteam_invitation.json new file mode 100644 index 000000000..9b3e04e69 --- /dev/null +++ b/templates/meta/careteam_invitation.json @@ -0,0 +1,20 @@ +{ + "name": "careteam_invitation", + "description": "email for those that invited someone to join medical crew", + "templateFilename": "careteam_invitation.html", + "subject": "CareTeamInvitationSubject", + "contentParts":[ + "CareTeamInviteHello", + "CareTeamInviteInvitation", + "CareTeamInviteClick", + "CareTeamInviteJoin", + "FooterOpenSource", + "FooterGetSupport", + "FooterSincerely", + "FooterSignature", + "FooterMadePossible" + ], + "escapeContentParts":[ + "CareteamName" + ] +} \ No newline at end of file diff --git a/templates/meta/no_account.json b/templates/meta/no_account.json new file mode 100644 index 000000000..0990af5b6 --- /dev/null +++ b/templates/meta/no_account.json @@ -0,0 +1,18 @@ +{ + "name": "no_account", + "description": "email for those that requested password reset but did not create their account before", + "templateFilename": "no_account.html", + "subject": "NoAccountSubject", + "contentParts":[ + "NoAccountHello", + "NoAccountNoAccount", + "NoAccountClick", + "NoAccountSignUp", + "FooterOpenSource", + "FooterGetSupport", + "FooterSincerely", + "FooterSignature", + "FooterMadePossible" + ], + "escapeContentParts":[] +} \ No newline at end of file diff --git a/templates/meta/password_reset.json b/templates/meta/password_reset.json new file mode 100644 index 000000000..d94fdc778 --- /dev/null +++ b/templates/meta/password_reset.json @@ -0,0 +1,19 @@ +{ + "name": "password_reset", + "description": "email for those that requested password reset", + "templateFilename": "password_reset.html", + "subject": "PasswordResetSubject", + "contentParts":[ + "PasswordResetHello", + "PasswordResetRequest", + "PasswordResetDidNotRequest", + "PasswordResetOtherwise", + "PasswordResetClick", + "FooterOpenSource", + "FooterGetSupport", + "FooterSincerely", + "FooterSignature", + "FooterMadePossible" + ], + "escapeContentParts":[] +} \ No newline at end of file diff --git a/templates/meta/signup_clinic_confirmation.json b/templates/meta/signup_clinic_confirmation.json new file mode 100644 index 000000000..2b3adcc33 --- /dev/null +++ b/templates/meta/signup_clinic_confirmation.json @@ -0,0 +1,17 @@ +{ + "name": "signup_clinic_confirmation", + "description": "email to ask clinician for verification of email", + "templateFilename": "signup_clinic_confirmation.html", + "subject": "SignupClinicConfirmationSubject", + "contentParts":[ + "SignupClinicHello", + "SignupClinicCongrats", + "SignupClinicVerify", + "FooterOpenSource", + "FooterGetSupport", + "FooterSincerely", + "FooterSignature", + "FooterMadePossible" + ], + "escapeContentParts":[] +} \ No newline at end of file diff --git a/templates/meta/signup_confirmation.json b/templates/meta/signup_confirmation.json new file mode 100644 index 000000000..6cc37f71a --- /dev/null +++ b/templates/meta/signup_confirmation.json @@ -0,0 +1,17 @@ +{ + "name": "signup_confirmation", + "description": "email to ask patient for verification of email", + "templateFilename": "signup_confirmation.html", + "subject": "SignupConfirmationSubject", + "contentParts":[ + "SignupHello", + "SignupCongrats", + "SignupVerify", + "FooterOpenSource", + "FooterGetSupport", + "FooterSincerely", + "FooterSignature", + "FooterMadePossible" + ], + "escapeContentParts":[] +} \ No newline at end of file diff --git a/templates/meta/signup_custodial_clinic_confirmation.json b/templates/meta/signup_custodial_clinic_confirmation.json new file mode 100644 index 000000000..4c49f070c --- /dev/null +++ b/templates/meta/signup_custodial_clinic_confirmation.json @@ -0,0 +1,21 @@ +{ + "name": "signup_custodial_clinic_confirmation", + "description": "email to ask clinician spawn by an existing clinician for verification of email and take ownership of his account", + "templateFilename": "signup_custodial_clinic_confirmation.html", + "subject": "SignupCustodialClinicConfirmationSubject", + "contentParts":[ + "SignupCustodialClinicHello", + "SignupCustodialClinicAccount", + "SignupCustodialClinicOwnership", + "SignupCustodialClinicClaim", + "FooterOpenSource", + "FooterGetSupport", + "FooterSincerely", + "FooterSignature", + "FooterMadePossible" + ], + "escapeContentParts":[ + "CreatorName", + "FullName" + ] +} \ No newline at end of file diff --git a/templates/meta/signup_custodial_confirmation.json b/templates/meta/signup_custodial_confirmation.json new file mode 100644 index 000000000..65e76b072 --- /dev/null +++ b/templates/meta/signup_custodial_confirmation.json @@ -0,0 +1,17 @@ +{ + "name": "signup_custodial_confirmation", + "description": "email to ask patient spawn by an existing clinician for verification of email and take ownership of his account", + "templateFilename": "signup_custodial_confirmation.html", + "subject": "SignupCustodialConfirmationSubject", + "contentParts":[ + "SignupCustodialHello", + "SignupCustodialCongrats", + "SignupCustodialVerify", + "FooterOpenSource", + "FooterGetSupport", + "FooterSincerely", + "FooterSignature", + "FooterMadePossible" + ], + "escapeContentParts":[] +} \ No newline at end of file diff --git a/templates/meta/test_template.json b/templates/meta/test_template.json new file mode 100644 index 000000000..ed3c91a5f --- /dev/null +++ b/templates/meta/test_template.json @@ -0,0 +1,12 @@ +{ + "name": "test_template", + "description": "email for testing purposes", + "templateFilename": "test_template.html", + "subject": "TestTemplateSubject", + "contentParts":[ + "TestContentInjection" + ], + "escapeContentParts":[ + "TestCreatorName" + ] +} \ No newline at end of file diff --git a/templates/signup_clinic.go b/templates/signup_clinic.go deleted file mode 100644 index 63245421e..000000000 --- a/templates/signup_clinic.go +++ /dev/null @@ -1,7 +0,0 @@ -package templates - -import "github.com/tidepool-org/hydrophone/models" - -func NewSignupClinicTemplate() (models.Template, error) { - return models.NewPrecompiledTemplate(models.TemplateNameSignupClinic, _SignupSubjectTemplate, _SignupBodyTemplate) -} diff --git a/templates/signup_custodial.go b/templates/signup_custodial.go deleted file mode 100644 index dc106dc6d..000000000 --- a/templates/signup_custodial.go +++ /dev/null @@ -1,7 +0,0 @@ -package templates - -import "github.com/tidepool-org/hydrophone/models" - -func NewSignupCustodialTemplate() (models.Template, error) { - return models.NewPrecompiledTemplate(models.TemplateNameSignupCustodial, _SignupSubjectTemplate, _SignupBodyTemplate) -} diff --git a/docs/templates-source/careteam_invite.html b/templates/source/careteam_invitation.html similarity index 90% rename from docs/templates-source/careteam_invite.html rename to templates/source/careteam_invitation.html index d398c7638..947e61f2b 100644 --- a/docs/templates-source/careteam_invite.html +++ b/templates/source/careteam_invitation.html @@ -31,10 +31,10 @@

- Hey there! + {{.CareTeamInviteHello}}

- {{ .CareteamName }} invited you to be on their care team.

Please click the link below to accept and see {{ .CareteamName }}’s data. + {{ .CareTeamInviteInvitation }}

{{.CareTeamInviteClick}}

@@ -46,7 +46,7 @@ - Join {{ .CareteamName }}'s Care Team + {{.CareTeamInviteJoin}} - Get Support + {{.FooterGetSupport}} - Sign Up + {{.NoAccountSignUp}} - Get Support + {{.FooterGetSupport}} - Reset Password + {{ .PasswordResetClick }} - Get Support + {{.FooterGetSupport}} - Verify Your Account + {{.SignupClinicVerify}} - Get Support + {{.FooterGetSupport}} - Claim Your Account + {{.SignupVerify}} - Get Support + {{.FooterGetSupport}} + + + + + + + + + +
+
+ + + + + +
+ + + + + + + + + + + + + + + + + + + + + + +
+

+ {{.SignupCustodialClinicHello}} +

+

+ {{.SignupCustodialClinicAccount}}

{{.SignupCustodialClinicOwnership}} +

+
+ + + {{.SignupCustodialClinicClaim}} + + +
+

{{.FooterSincerely}}
{{.FooterSignature}}

+
+ +
+ + + + + + + + +
+

+ Tidepool + {{.FooterOpenSource}} +

+
+ + + + + + + +
+
+ +
+
+ + diff --git a/templates/source/signup_custodial_confirmation.html b/templates/source/signup_custodial_confirmation.html new file mode 100644 index 000000000..c321eb9d7 --- /dev/null +++ b/templates/source/signup_custodial_confirmation.html @@ -0,0 +1,147 @@ + + + + + + + + + + + + + + + +
+
+ + + + + +
+ + + + + + + + + + + + + + + + + + + + + + +
+

+ {{.SignupCustodialHello}} +

+

+ {{.SignupCustodialCongrats}} +

+
+ + + {{.SignupCustodialVerify}} + + +
+

{{.FooterSincerely}}
{{.FooterSignature}}

+
+ +
+ + + + + + + + +
+

+ Tidepool + {{.FooterOpenSource}} +

+
+ + + + + + + +
+
+ +
+
+ + diff --git a/templates/templates.go b/templates/templates.go index f9bb6adb2..3baa86b0c 100644 --- a/templates/templates.go +++ b/templates/templates.go @@ -1,51 +1,64 @@ package templates import ( + "encoding/json" "fmt" + "io/ioutil" + "log" + "os" "github.com/tidepool-org/hydrophone/models" ) -func New() (models.Templates, error) { +type TemplateMeta struct { + Name string `json:"name"` + Description string `json:"description"` + TemplateFilename string `json:"templateFilename"` + ContentParts []string `json:"contentParts"` + Subject string `json:"subject"` + EscapeContentParts []string `json:"escapeContentParts"` +} + +func New(templatesPath string) (models.Templates, error) { templates := models.Templates{} - if template, err := NewCareteamInviteTemplate(); err != nil { + if template, err := NewTemplate(templatesPath, models.TemplateNameCareteamInvite); err != nil { return nil, fmt.Errorf("templates: failure to create careteam invite template: %s", err) } else { templates[template.Name()] = template } - if template, err := NewNoAccountTemplate(); err != nil { + if template, err := NewTemplate(templatesPath, models.TemplateNameNoAccount); err != nil { return nil, fmt.Errorf("templates: failure to create no account template: %s", err) } else { templates[template.Name()] = template } - if template, err := NewPasswordResetTemplate(); err != nil { + if template, err := NewTemplate(templatesPath, models.TemplateNamePasswordReset); err != nil { return nil, fmt.Errorf("templates: failure to create password reset template: %s", err) } else { templates[template.Name()] = template } - if template, err := NewSignupTemplate(); err != nil { + if template, err := NewTemplate(templatesPath, models.TemplateNameSignup); err != nil { return nil, fmt.Errorf("templates: failure to create signup template: %s", err) } else { templates[template.Name()] = template } - if template, err := NewSignupClinicTemplate(); err != nil { + if template, err := NewTemplate(templatesPath, models.TemplateNameSignupClinic); err != nil { return nil, fmt.Errorf("templates: failure to create signup clinic template: %s", err) } else { templates[template.Name()] = template } - if template, err := NewSignupCustodialTemplate(); err != nil { + if template, err := NewTemplate(templatesPath, models.TemplateNameSignupCustodial); err != nil { return nil, fmt.Errorf("templates: failure to create signup custodial template: %s", err) } else { templates[template.Name()] = template } - if template, err := NewSignupCustodialClinicTemplate(); err != nil { + if template, err := NewTemplate(templatesPath, models.TemplateNameSignupCustodialClinic); err != nil { return nil, fmt.Errorf("templates: failure to create signup custodial clinic template: %s", err) } else { templates[template.Name()] = template @@ -53,3 +66,52 @@ func New() (models.Templates, error) { return templates, nil } + +//NewTemplate returns the requested template +//templateName is the name of the template to be returned +func NewTemplate(templatesPath string, templateName models.TemplateName) (models.Template, error) { + // Get template Metadata + var templateMeta = getTemplateMeta(templatesPath + "/meta/" + string(templateName) + ".json") + var templateFileName = templatesPath + "/html/" + templateMeta.TemplateFilename + + return models.NewPrecompiledTemplate(templateName, templateMeta.Subject, getBodySkeleton(templateFileName), templateMeta.ContentParts, templateMeta.EscapeContentParts) +} + +// getTemplateMeta returns the template metadata +// Metadata are information that relate to a template (e.g. name, templateFilename...) +// Inputs: +// metaFileName = name of the file with no path and no json extension, assuming the file is located in path specified in TIDEPOOL_HYDROPHONE_SERVICE environment variable +func getTemplateMeta(metaFileName string) TemplateMeta { + log.Printf("getting template meta from %s", metaFileName) + + // Open the jsonFile + jsonFile, err := os.Open(metaFileName) + if err != nil { + fmt.Println(err) + } + + // defer the closing of our jsonFile so that we can parse it later on + defer jsonFile.Close() + + // read the opened xmlFile as a byte array. + byteValue, _ := ioutil.ReadAll(jsonFile) + + var meta TemplateMeta + + // we unmarshal our byteArray which contains our + // jsonFile's content into 'users' which we defined above + json.Unmarshal(byteValue, &meta) + + return meta +} + +// getBodySkeleton returns the email body skeleton (without content) from the file which name is in input parameter +func getBodySkeleton(fileName string) string { + + data, err := ioutil.ReadFile(fileName) + if err != nil { + log.Printf("templates - failure to get template body: %s", err) + } + log.Printf("getting template body from %s", fileName) + return string(data) +} diff --git a/templates/templates_test.go b/templates/templates_test.go new file mode 100644 index 000000000..2faa44b85 --- /dev/null +++ b/templates/templates_test.go @@ -0,0 +1,36 @@ +package templates + +import ( + "testing" +) + +const ( + templatesPath = "." + templateName = "test_template" +) + +func Test_GetTemplateMeta(t *testing.T) { + var expectedTemplateSubject = "TestTemplateSubject" + // Get template Metadata + var templateMeta = getTemplateMeta(templatesPath + "/meta/" + templateName + ".json") + + if templateMeta.Subject == "" { + t.Fatal("Template Meta cannot be found") + } + + if templateMeta.Subject != expectedTemplateSubject { + t.Fatalf("Template Meta is found but subject \"%s\" not the one expected \"%s\"", templateMeta.Subject, expectedTemplateSubject) + } +} + +func Test_GetBodySkeleton(t *testing.T) { + // Get template Metadata + var templateMeta = getTemplateMeta(templatesPath + "/meta/" + templateName + ".json") + var templateFileName = templatesPath + "/html/" + templateMeta.TemplateFilename + + var templateBody = getBodySkeleton(templateFileName) + + if templateBody == "" { + t.Fatalf("Template Body cannot be found: %s", templateFileName) + } +}