Skip to content

Commit 698eb37

Browse files
committed
Email on space creation
1 parent c08d4b0 commit 698eb37

File tree

13 files changed

+286
-17
lines changed

13 files changed

+286
-17
lines changed

.env.sample

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,8 @@ DBMATE_MIGRATIONS_DIR="./sql/migrations"
55
DBMATE_SCHEMA_FILE="./sql/schema.sql"
66
INVITATION_CODE="CODE123"
77
COOKIE_SECRET="dev_secret"
8+
SMTP_HOST="localhost"
9+
SMTP_PORT=1025
10+
SMTP_USERNAME=""
11+
SMTP_PASSWORD=""
12+
EMAIL_FROM="Simply Shared Notes <no-reply@local.test>"

app/app.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010

1111
"github.com/nicolashery/simply-shared-notes/app/config"
1212
"github.com/nicolashery/simply-shared-notes/app/db"
13+
"github.com/nicolashery/simply-shared-notes/app/email"
1314
"github.com/nicolashery/simply-shared-notes/app/server"
1415
"github.com/nicolashery/simply-shared-notes/app/session"
1516
"github.com/nicolashery/simply-shared-notes/app/vite"
@@ -45,7 +46,12 @@ func Run(ctx context.Context, distFS embed.FS, pragmasSQL string) error {
4546

4647
sessionStore := session.InitStore(cfg.CookieSecret, cfg.IsDev)
4748

48-
s := server.New(cfg, logger, sqlDB, queries, vite, sessionStore)
49+
email, err := email.New(cfg)
50+
if err != nil {
51+
return err
52+
}
53+
54+
s := server.New(cfg, logger, sqlDB, queries, vite, sessionStore, email)
4955

5056
return server.Run(ctx, s, logger, cfg.Port)
5157
}

app/config/config.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ type Config struct {
1515
DatabaseURL string `env:"DATABASE_URL" envDefault:"sqlite:data/app.sqlite"`
1616
InvitationCode string `env:"INVITATION_CODE"`
1717
CookieSecret string `env:"COOKIE_SECRET,required"`
18+
SMTPHost string `env:"SMTP_HOST" envDefault:"localhost"`
19+
SMTPPort int `env:"SMTP_PORT" envDefault:"1025"`
20+
SMTPUsername string `env:"SMTP_USERNAME"`
21+
SMTPPassword string `env:"SMTP_PASSWORD"`
22+
EmailFrom string `env:"EMAIL_FROM" envDefault:"Simply Shared Notes <no-reply@local.test>"`
1823
}
1924

2025
func New() (*Config, error) {

app/email/email.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package email
2+
3+
import (
4+
"github.com/nicolashery/simply-shared-notes/app/config"
5+
"github.com/wneessen/go-mail"
6+
)
7+
8+
type Email struct {
9+
Client *mail.Client
10+
EmailFrom string
11+
}
12+
13+
func New(cfg *config.Config) (*Email, error) {
14+
var client *mail.Client
15+
var err error
16+
if cfg.SMTPUsername == "" || cfg.SMTPPassword == "" {
17+
client, err = mail.NewClient(
18+
cfg.SMTPHost,
19+
mail.WithPort(cfg.SMTPPort),
20+
mail.WithTLSPortPolicy(mail.NoTLS),
21+
)
22+
} else {
23+
client, err = mail.NewClient(
24+
cfg.SMTPHost,
25+
mail.WithPort(cfg.SMTPPort),
26+
mail.WithTLSPortPolicy(mail.TLSMandatory),
27+
mail.WithSMTPAuth(mail.SMTPAuthPlain),
28+
mail.WithUsername(cfg.SMTPUsername),
29+
mail.WithPassword(cfg.SMTPPassword),
30+
)
31+
}
32+
if err != nil {
33+
return nil, err
34+
}
35+
36+
return &Email{
37+
Client: client,
38+
EmailFrom: cfg.EmailFrom,
39+
}, nil
40+
}
41+
42+
func (e *Email) Send(to string, subject string, text string) error {
43+
message := mail.NewMsg()
44+
if err := message.From(e.EmailFrom); err != nil {
45+
return err
46+
}
47+
if err := message.To(to); err != nil {
48+
return err
49+
}
50+
message.Subject(subject)
51+
message.SetBodyString(mail.TypeTextPlain, text)
52+
53+
return e.Client.DialAndSend(message)
54+
}

app/handlers/helpers.handlers.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,14 @@ func redirectFromReferer(r *http.Request) string {
4545
return safeRedirect(u.RequestURI())
4646
}
4747

48+
func baseUrlFromRequest(r *http.Request) string {
49+
scheme := "http"
50+
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
51+
scheme = "https"
52+
}
53+
return scheme + "://" + r.Host
54+
}
55+
4856
func memberListToMap(members []db.Member) map[int64]db.Member {
4957
memberMap := make(map[int64]db.Member, len(members))
5058
for _, member := range members {

app/handlers/routes.go

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,27 @@ import (
99
"github.com/nicolashery/simply-shared-notes/app/access"
1010
"github.com/nicolashery/simply-shared-notes/app/config"
1111
"github.com/nicolashery/simply-shared-notes/app/db"
12+
"github.com/nicolashery/simply-shared-notes/app/email"
1213
"github.com/nicolashery/simply-shared-notes/app/rctx"
1314
)
1415

15-
func RegisterRoutes(r chi.Router, cfg *config.Config, logger *slog.Logger, sqlDB *sql.DB, queries *db.Queries, sessionStore *sessions.CookieStore) {
16+
func RegisterRoutes(
17+
r chi.Router,
18+
cfg *config.Config,
19+
logger *slog.Logger,
20+
sqlDB *sql.DB,
21+
queries *db.Queries,
22+
sessionStore *sessions.CookieStore,
23+
email *email.Email,
24+
) {
1625
r.Use(rctx.SessionCtxMiddleware(logger, sessionStore))
1726
r.Use(rctx.ThemeCtxMiddleware())
1827

1928
r.Get("/", handleHome(logger))
2029

2130
r.Get("/new", handleSpacesNew(cfg, logger))
22-
r.Post("/new", handleSpacesCreate(cfg, logger, sqlDB, queries))
31+
r.Post("/new", handleSpacesCreate(cfg, logger, sqlDB, queries, email))
32+
r.With(rctx.FlashCtxMiddleware(logger)).Get("/new/success", handleSpacesNewSuccess(logger))
2333

2434
r.Get("/language", handleLanguageSelect(logger))
2535
r.Get("/theme", handleThemeSelect(logger))

app/handlers/spaces.handlers.go

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,12 @@ import (
1111
"github.com/nicolashery/simply-shared-notes/app/access"
1212
"github.com/nicolashery/simply-shared-notes/app/config"
1313
"github.com/nicolashery/simply-shared-notes/app/db"
14+
"github.com/nicolashery/simply-shared-notes/app/email"
1415
"github.com/nicolashery/simply-shared-notes/app/forms"
1516
"github.com/nicolashery/simply-shared-notes/app/publicid"
1617
"github.com/nicolashery/simply-shared-notes/app/rctx"
1718
"github.com/nicolashery/simply-shared-notes/app/session"
19+
"github.com/nicolashery/simply-shared-notes/app/views/emails"
1820
"github.com/nicolashery/simply-shared-notes/app/views/pages"
1921
)
2022

@@ -39,7 +41,7 @@ func handleSpacesNew(cfg *config.Config, logger *slog.Logger) http.HandlerFunc {
3941
}
4042
}
4143

42-
func handleSpacesCreate(cfg *config.Config, logger *slog.Logger, sqlDB *sql.DB, queries *db.Queries) http.HandlerFunc {
44+
func handleSpacesCreate(cfg *config.Config, logger *slog.Logger, sqlDB *sql.DB, queries *db.Queries, email *email.Email) http.HandlerFunc {
4345
return func(w http.ResponseWriter, r *http.Request) {
4446
requiresCode := cfg.RequiresInvitationCode()
4547

@@ -110,11 +112,25 @@ func handleSpacesCreate(cfg *config.Config, logger *slog.Logger, sqlDB *sql.DB,
110112
return
111113
}
112114

115+
emailSubject := emails.SpaceCreatedSubject(space)
116+
baseURL := baseUrlFromRequest(r)
117+
emailText := emails.SpaceCreatedText(member.Name, space, baseURL, tokens)
118+
err = email.Send(space.Email, emailSubject, emailText)
119+
if err != nil {
120+
logger.Error(
121+
"failed to send email",
122+
slog.Any("error", err),
123+
slog.String("email", "space_created"),
124+
)
125+
}
126+
113127
sess := rctx.GetSession(r.Context())
114-
sess.Values[session.IdentityKey] = member.ID
115128
sess.AddFlash(session.FlashMessage{
116-
Type: session.FlashType_Info,
117-
Content: fmt.Sprintf("%s, welcome to the space %s!", member.Name, space.Name),
129+
Type: session.FlashType_Success,
130+
Content: fmt.Sprintf(
131+
"Your new space %s was created! Check your emails for the link to the space at: %s!",
132+
space.Name, space.Email,
133+
),
118134
})
119135
err = sess.Save(r, w)
120136
if err != nil {
@@ -123,7 +139,21 @@ func handleSpacesCreate(cfg *config.Config, logger *slog.Logger, sqlDB *sql.DB,
123139
return
124140
}
125141

126-
http.Redirect(w, r, fmt.Sprintf("/s/%s", space.AdminToken), http.StatusSeeOther)
142+
http.Redirect(w, r, "/new/success", http.StatusSeeOther)
143+
}
144+
}
145+
146+
func handleSpacesNewSuccess(logger *slog.Logger) http.HandlerFunc {
147+
return func(w http.ResponseWriter, r *http.Request) {
148+
err := pages.SpacesNewSuccess().Render(r.Context(), w)
149+
if err != nil {
150+
logger.Error(
151+
"failed to render template",
152+
slog.Any("error", err),
153+
slog.String("template", "SpacesNewSuccess"),
154+
)
155+
http.Error(w, "internal server error", http.StatusInternalServerError)
156+
}
127157
}
128158
}
129159

@@ -396,11 +426,7 @@ func handleTokensShow(logger *slog.Logger) http.HandlerFunc {
396426
ViewToken: space.ViewToken,
397427
}
398428

399-
scheme := "http"
400-
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
401-
scheme = "https"
402-
}
403-
baseURL := scheme + "://" + r.Host
429+
baseURL := baseUrlFromRequest(r)
404430

405431
err := pages.TokensShow(baseURL, tokens).Render(r.Context(), w)
406432
if err != nil {

app/server/server.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,21 @@ import (
1212
"github.com/gorilla/sessions"
1313
"github.com/nicolashery/simply-shared-notes/app/config"
1414
"github.com/nicolashery/simply-shared-notes/app/db"
15+
"github.com/nicolashery/simply-shared-notes/app/email"
1516
"github.com/nicolashery/simply-shared-notes/app/handlers"
1617
"github.com/nicolashery/simply-shared-notes/app/rctx"
1718
"github.com/nicolashery/simply-shared-notes/app/vite"
1819
)
1920

20-
func New(cfg *config.Config, logger *slog.Logger, sqlDB *sql.DB, queries *db.Queries, vite *vite.Vite, sessionStore *sessions.CookieStore) http.Handler {
21+
func New(
22+
cfg *config.Config,
23+
logger *slog.Logger,
24+
sqlDB *sql.DB,
25+
queries *db.Queries,
26+
vite *vite.Vite,
27+
sessionStore *sessions.CookieStore,
28+
email *email.Email,
29+
) http.Handler {
2130
router := chi.NewRouter()
2231

2332
router.Use(
@@ -26,7 +35,7 @@ func New(cfg *config.Config, logger *slog.Logger, sqlDB *sql.DB, queries *db.Que
2635
rctx.ViteCtxMiddleware(vite),
2736
)
2837

29-
handlers.RegisterRoutes(router, cfg, logger, sqlDB, queries, sessionStore)
38+
handlers.RegisterRoutes(router, cfg, logger, sqlDB, queries, sessionStore, email)
3039

3140
StaticDir(router, "/assets", vite.AssetsFS)
3241
StaticFile(router, "/robots.txt", vite.PublicFS)
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package emails
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/nicolashery/simply-shared-notes/app/access"
7+
"github.com/nicolashery/simply-shared-notes/app/db"
8+
)
9+
10+
func SpaceCreatedSubject(space *db.Space) string {
11+
return fmt.Sprintf("Your space was created: %s", space.Name)
12+
}
13+
14+
func SpaceCreatedText(memberName string, space *db.Space, baseURL string, tokens access.AccessTokens) string {
15+
return fmt.Sprintf(
16+
"Hi %s!\n\n"+
17+
"You created a new space for your notes: %s.\n\n"+
18+
"Share this link with anyone you want to collaborate with in this space (editor access):\n"+
19+
"%s/s/%s\n\n"+
20+
"You can instead share this link if you don't want to allow edits (view-only access):\n"+
21+
"%s/s/%s\n\n"+
22+
"Finally, keep this link for yourself as it will allow you to do anything in this space (admin access):\n"+
23+
"%s/s/%s\n\n"+
24+
"Enjoy,\n"+
25+
"- Simply Shared Notes",
26+
memberName, space.Name,
27+
baseURL, tokens.EditToken,
28+
baseURL, tokens.ViewToken,
29+
baseURL, tokens.ViewToken,
30+
)
31+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package pages
2+
3+
import (
4+
"github.com/nicolashery/simply-shared-notes/app/rctx"
5+
"github.com/nicolashery/simply-shared-notes/app/session"
6+
"github.com/nicolashery/simply-shared-notes/app/views/components"
7+
"github.com/nicolashery/simply-shared-notes/app/views/layouts"
8+
)
9+
10+
templ SpacesNewSuccess() {
11+
{{ messages := rctx.GetFlashMessages(ctx) }}
12+
@layouts.Landing("Create space") {
13+
<div class="max-w-md mx-auto py-4">
14+
<h1 class="text-2xl font-bold mb-6">Create a new space</h1>
15+
if len(messages) > 0 {
16+
@components.FlashMessage(&messages[0])
17+
} else {
18+
@components.FlashMessage(&session.FlashMessage{
19+
Type: session.FlashType_Success,
20+
Content: "Your new space was created! Check your emails for the link to the space.",
21+
})
22+
}
23+
</div>
24+
}
25+
}

0 commit comments

Comments
 (0)