diff --git a/conf/taskcafe.example.toml b/conf/taskcafe.example.toml index 62154d3..40c42d7 100644 --- a/conf/taskcafe.example.toml +++ b/conf/taskcafe.example.toml @@ -17,8 +17,9 @@ user = 'taskcafe' password = 'taskcafe_test' [smtp] -username = 'admin@example.com' -password = 'example' -server = 'mail.example.com' -port = 465 -connection_security = 'STARTTLS' +username = 'taskcafe@example.com' +password = '' +from = 'no-reply@taskcafe.com' +host = 'localhost' +port = 11500 +skip_verify = false diff --git a/internal/commands/web.go b/internal/commands/web.go index 5a128a9..439d604 100644 --- a/internal/commands/web.go +++ b/internal/commands/web.go @@ -15,6 +15,7 @@ import ( "github.com/jmoiron/sqlx" "github.com/jordanknott/taskcafe/internal/route" + "github.com/jordanknott/taskcafe/internal/utils" log "github.com/sirupsen/logrus" ) @@ -75,11 +76,25 @@ func newWebCmd() *cobra.Command { log.Warn("server.secret is not set, generating a random secret") secret = uuid.New().String() } - r, _ := route.NewRouter(db, []byte(secret)) + r, _ := route.NewRouter(db, utils.EmailConfig{ + From: viper.GetString("smtp.from"), + Host: viper.GetString("smtp.host"), + Port: viper.GetInt("smtp.port"), + Username: viper.GetString("smtp.username"), + Password: viper.GetString("smtp.password"), + InsecureSkipVerify: viper.GetBool("smtp.skip_verify"), + }, []byte(secret)) return http.ListenAndServe(viper.GetString("server.hostname"), r) }, } + viper.SetDefault("smtp.from", "no-reply@example.com") + viper.SetDefault("smtp.host", "localhost") + viper.SetDefault("smtp.port", 587) + viper.SetDefault("smtp.username", "") + viper.SetDefault("smtp.password", "") + viper.SetDefault("smtp.skip_verify", false) + cc.Flags().Bool("migrate", false, "if true, auto run's schema migrations before starting the web server") viper.BindPFlag("migrate", cc.Flags().Lookup("migrate")) diff --git a/internal/graph/graph.go b/internal/graph/graph.go index e09dd45..20a1227 100644 --- a/internal/graph/graph.go +++ b/internal/graph/graph.go @@ -26,10 +26,11 @@ import ( ) // NewHandler returns a new graphql endpoint handler. -func NewHandler(repo db.Repository) http.Handler { +func NewHandler(repo db.Repository, emailConfig utils.EmailConfig) http.Handler { c := Config{ Resolvers: &Resolver{ - Repository: repo, + Repository: repo, + EmailConfig: emailConfig, }, } c.Directives.HasRole = func(ctx context.Context, obj interface{}, next graphql.Resolver, roles []RoleLevel, level ActionLevel, typeArg ObjectType) (interface{}, error) { diff --git a/internal/graph/resolver.go b/internal/graph/resolver.go index 2b86ead..16b868c 100644 --- a/internal/graph/resolver.go +++ b/internal/graph/resolver.go @@ -7,10 +7,12 @@ import ( "sync" "github.com/jordanknott/taskcafe/internal/db" + "github.com/jordanknott/taskcafe/internal/utils" ) // Resolver handles resolving GraphQL queries & mutations type Resolver struct { - Repository db.Repository - mu sync.Mutex + Repository db.Repository + EmailConfig utils.EmailConfig + mu sync.Mutex } diff --git a/internal/graph/schema.resolvers.go b/internal/graph/schema.resolvers.go index 6848cee..2cc18f8 100644 --- a/internal/graph/schema.resolvers.go +++ b/internal/graph/schema.resolvers.go @@ -5,7 +5,6 @@ package graph import ( "context" - "crypto/tls" "database/sql" "encoding/json" "errors" @@ -16,12 +15,11 @@ import ( "github.com/jordanknott/taskcafe/internal/auth" "github.com/jordanknott/taskcafe/internal/db" "github.com/jordanknott/taskcafe/internal/logger" + "github.com/jordanknott/taskcafe/internal/utils" "github.com/lithammer/fuzzysearch/fuzzy" - hermes "github.com/matcornic/hermes/v2" log "github.com/sirupsen/logrus" "github.com/vektah/gqlparser/v2/gqlerror" "golang.org/x/crypto/bcrypt" - gomail "gopkg.in/mail.v2" ) func (r *labelColorResolver) ID(ctx context.Context, obj *db.LabelColor) (uuid.UUID, error) { @@ -193,79 +191,11 @@ func (r *mutationResolver) InviteProjectMembers(ctx context.Context, input Invit if err != nil { return &InviteProjectMembersPayload{Ok: false}, err } - // send out invitation - // add project invite entry - // send out notification? - h := hermes.Hermes{ - // Optional Theme - Product: hermes.Product{ - // Appears in header & footer of e-mails - Name: "Taskscafe", - Link: "http://localhost:3333/", - // Optional product logo - Logo: "https://github.com/JordanKnott/taskcafe/raw/master/.github/taskcafe-full.png", - }, - } - - email := hermes.Email{ - Body: hermes.Body{ - Name: "Jordan Knott", - Intros: []string{ - "You have been invited to join Taskcafe", - }, - Actions: []hermes.Action{ - { - Instructions: "To get started with Taskcafe, please click here:", - Button: hermes.Button{ - Color: "#7367F0", // Optional action button color - TextColor: "#FFFFFF", - Text: "Register your account", - Link: "http://localhost:3000/register?confirmToken=" + confirmToken.ConfirmTokenID.String(), - }, - }, - }, - Outros: []string{ - "Need help, or have questions? Just reply to this email, we'd love to help.", - }, - }, - } - - // Generate an HTML email with the provided contents (for modern clients) - emailBody, err := h.GenerateHTML(email) + invite := utils.EmailInvite{To: *invitedMember.Email, FullName: *invitedMember.Email, ConfirmToken: confirmToken.ConfirmTokenID.String()} + err = utils.SendEmailInvite(r.EmailConfig, invite) if err != nil { - panic(err) // Tip: Handle error with something else than a panic ;) - } - emailBodyPlain, err := h.GeneratePlainText(email) - if err != nil { - panic(err) // Tip: Handle error with something else than a panic ;) - } - - m := gomail.NewMessage() - - // Set E-Mail sender - m.SetHeader("From", "no-reply@taskcafe.com") - - // Set E-Mail receivers - m.SetHeader("To", invitedUser.Email) - - // Set E-Mail subject - m.SetHeader("Subject", "You have been invited to Taskcafe") - - // Set E-Mail body. You can set plain text or html with text/html - m.SetBody("text/html", emailBody) - m.AddAlternative("text/plain", emailBodyPlain) - - // Settings for SMTP server - d := gomail.NewDialer("127.0.0.1", 11500, "no-reply@taskcafe.com", "") - - // This is only needed when SSL/TLS certificate is not valid on server. - // In production this should be set to false. - d.TLSConfig = &tls.Config{InsecureSkipVerify: true} - - // Now send E-Mail - if err := d.DialAndSend(m); err != nil { - fmt.Println(err) - panic(err) + logger.New(ctx).WithError(err).Error("issue sending email") + return &InviteProjectMembersPayload{Ok: false}, err } } else { return &InviteProjectMembersPayload{Ok: false}, err diff --git a/internal/route/route.go b/internal/route/route.go index ad9e6e8..e3e4764 100644 --- a/internal/route/route.go +++ b/internal/route/route.go @@ -16,6 +16,7 @@ import ( "github.com/jordanknott/taskcafe/internal/frontend" "github.com/jordanknott/taskcafe/internal/graph" "github.com/jordanknott/taskcafe/internal/logger" + "github.com/jordanknott/taskcafe/internal/utils" ) // FrontendHandler serves an embed React client through chi @@ -64,7 +65,7 @@ type TaskcafeHandler struct { } // NewRouter creates a new router for chi -func NewRouter(dbConnection *sqlx.DB, jwtKey []byte) (chi.Router, error) { +func NewRouter(dbConnection *sqlx.DB, emailConfig utils.EmailConfig, jwtKey []byte) (chi.Router, error) { formatter := new(log.TextFormatter) formatter.TimestampFormat = "02-01-2006 15:04:05" formatter.FullTimestamp = true @@ -94,7 +95,7 @@ func NewRouter(dbConnection *sqlx.DB, jwtKey []byte) (chi.Router, error) { r.Group(func(mux chi.Router) { mux.Use(auth.Middleware) mux.Post("/users/me/avatar", taskcafeHandler.ProfileImageUpload) - mux.Handle("/graphql", graph.NewHandler(*repository)) + mux.Handle("/graphql", graph.NewHandler(*repository, emailConfig)) }) frontend := FrontendHandler{staticPath: "build", indexPath: "index.html"} diff --git a/internal/utils/mail.go b/internal/utils/mail.go new file mode 100644 index 0000000..bab8273 --- /dev/null +++ b/internal/utils/mail.go @@ -0,0 +1,94 @@ +package utils + +import ( + "crypto/tls" + + hermes "github.com/matcornic/hermes/v2" + gomail "gopkg.in/mail.v2" +) + +type EmailConfig struct { + Host string + Port int + From string + Username string + Password string + SiteURL string + InsecureSkipVerify bool +} + +type EmailInvite struct { + ConfirmToken string + FullName string + To string +} + +func SendEmailInvite(config EmailConfig, invite EmailInvite) error { + h := hermes.Hermes{ + Product: hermes.Product{ + Name: "Taskscafe", + Link: config.SiteURL, + Logo: "https://github.com/JordanKnott/taskcafe/raw/master/.github/taskcafe-full.png", + }, + } + + email := hermes.Email{ + Body: hermes.Body{ + Name: invite.FullName, + Intros: []string{ + "You have been invited to join Taskcafe", + }, + Actions: []hermes.Action{ + { + Instructions: "To get started with Taskcafe, please click here:", + Button: hermes.Button{ + Color: "#7367F0", // Optional action button color + TextColor: "#FFFFFF", + Text: "Register your account", + Link: config.SiteURL + "/register?confirmToken=" + invite.ConfirmToken, + }, + }, + }, + Outros: []string{ + "Need help, or have questions? Just reply to this email, we'd love to help.", + }, + }, + } + + emailBody, err := h.GenerateHTML(email) + if err != nil { + return err + } + emailBodyPlain, err := h.GeneratePlainText(email) + if err != nil { + return err + } + + m := gomail.NewMessage() + + // Set E-Mail sender + m.SetHeader("From", config.From) + + // Set E-Mail receivers + m.SetHeader("To", invite.To) + + // Set E-Mail subject + m.SetHeader("Subject", "You have been invited to Taskcafe") + + // Set E-Mail body. You can set plain text or html with text/html + m.SetBody("text/html", emailBody) + m.AddAlternative("text/plain", emailBodyPlain) + + // Settings for SMTP server + d := gomail.NewDialer(config.Host, config.Port, config.Username, config.Password) + + // This is only needed when SSL/TLS certificate is not valid on server. + // In production this should be set to false. + d.TLSConfig = &tls.Config{InsecureSkipVerify: config.InsecureSkipVerify} + + // Now send E-Mail + if err := d.DialAndSend(m); err != nil { + return err + } + return nil +}