mirror of https://github.com/writeas/writefreely
commit
64dcb56793
@ -0,0 +1,462 @@ |
||||
/* |
||||
* Copyright © 2019-2021 Musing Studio LLC. |
||||
* |
||||
* This file is part of WriteFreely. |
||||
* |
||||
* WriteFreely is free software: you can redistribute it and/or modify |
||||
* it under the terms of the GNU Affero General Public License, included |
||||
* in the LICENSE file in this source code package. |
||||
*/ |
||||
|
||||
package writefreely |
||||
|
||||
import ( |
||||
"database/sql" |
||||
"encoding/json" |
||||
"fmt" |
||||
"html/template" |
||||
"net/http" |
||||
"strings" |
||||
"time" |
||||
|
||||
"github.com/aymerick/douceur/inliner" |
||||
"github.com/gorilla/mux" |
||||
"github.com/mailgun/mailgun-go" |
||||
stripmd "github.com/writeas/go-strip-markdown/v2" |
||||
"github.com/writeas/impart" |
||||
"github.com/writeas/web-core/data" |
||||
"github.com/writeas/web-core/log" |
||||
"github.com/writefreely/writefreely/key" |
||||
"github.com/writefreely/writefreely/spam" |
||||
) |
||||
|
||||
const ( |
||||
emailSendDelay = 15 |
||||
) |
||||
|
||||
type ( |
||||
SubmittedSubscription struct { |
||||
CollAlias string |
||||
UserID int64 |
||||
|
||||
Email string `schema:"email" json:"email"` |
||||
Web bool `schema:"web" json:"web"` |
||||
Slug string `schema:"slug" json:"slug"` |
||||
From string `schema:"from" json:"from"` |
||||
} |
||||
|
||||
EmailSubscriber struct { |
||||
ID string |
||||
CollID int64 |
||||
UserID sql.NullInt64 |
||||
Email sql.NullString |
||||
Subscribed time.Time |
||||
Token string |
||||
Confirmed bool |
||||
AllowExport bool |
||||
acctEmail sql.NullString |
||||
} |
||||
) |
||||
|
||||
func (es *EmailSubscriber) FinalEmail(keys *key.Keychain) string { |
||||
if !es.UserID.Valid || es.Email.Valid { |
||||
return es.Email.String |
||||
} |
||||
|
||||
decEmail, err := data.Decrypt(keys.EmailKey, []byte(es.acctEmail.String)) |
||||
if err != nil { |
||||
log.Error("Error decrypting user email: %v", err) |
||||
return "" |
||||
} |
||||
return string(decEmail) |
||||
} |
||||
|
||||
func (es *EmailSubscriber) SubscribedFriendly() string { |
||||
return es.Subscribed.Format("January 2, 2006") |
||||
} |
||||
|
||||
func handleCreateEmailSubscription(app *App, w http.ResponseWriter, r *http.Request) error { |
||||
reqJSON := IsJSON(r) |
||||
vars := mux.Vars(r) |
||||
var err error |
||||
|
||||
ss := SubmittedSubscription{ |
||||
CollAlias: vars["alias"], |
||||
} |
||||
u := getUserSession(app, r) |
||||
if u != nil { |
||||
ss.UserID = u.ID |
||||
} |
||||
if reqJSON { |
||||
// Decode JSON request
|
||||
decoder := json.NewDecoder(r.Body) |
||||
err = decoder.Decode(&ss) |
||||
if err != nil { |
||||
log.Error("Couldn't parse new subscription JSON request: %v\n", err) |
||||
return ErrBadJSON |
||||
} |
||||
} else { |
||||
err = r.ParseForm() |
||||
if err != nil { |
||||
log.Error("Couldn't parse new subscription form request: %v\n", err) |
||||
return ErrBadFormData |
||||
} |
||||
|
||||
err = app.formDecoder.Decode(&ss, r.PostForm) |
||||
if err != nil { |
||||
log.Error("Continuing, but error decoding new subscription form request: %v\n", err) |
||||
//return ErrBadFormData
|
||||
} |
||||
} |
||||
|
||||
c, err := app.db.GetCollection(ss.CollAlias) |
||||
if err != nil { |
||||
log.Error("getCollection: %s", err) |
||||
return err |
||||
} |
||||
c.hostName = app.cfg.App.Host |
||||
|
||||
from := c.CanonicalURL() |
||||
isAuthorBanned, err := app.db.IsUserSilenced(c.OwnerID) |
||||
if isAuthorBanned { |
||||
log.Info("Author is silenced, so subscription is blocked.") |
||||
return impart.HTTPError{http.StatusFound, from} |
||||
} |
||||
|
||||
if ss.Web { |
||||
if u != nil && u.ID == c.OwnerID { |
||||
from = "/" + c.Alias + "/" |
||||
} |
||||
from += ss.Slug |
||||
} |
||||
|
||||
if r.FormValue(spam.HoneypotFieldName()) != "" || r.FormValue("fake_password") != "" { |
||||
log.Info("Honeypot field was filled out! Not subscribing.") |
||||
return impart.HTTPError{http.StatusFound, from} |
||||
} |
||||
|
||||
if ss.Email == "" && ss.UserID < 1 { |
||||
log.Info("No subscriber data. Not subscribing.") |
||||
return impart.HTTPError{http.StatusFound, from} |
||||
} |
||||
|
||||
confirmed := app.db.IsSubscriberConfirmed(ss.Email) |
||||
es, err := app.db.AddEmailSubscription(c.ID, ss.UserID, ss.Email, confirmed) |
||||
if err != nil { |
||||
log.Error("addEmailSubscription: %s", err) |
||||
return err |
||||
} |
||||
|
||||
// Send confirmation email if needed
|
||||
if !confirmed { |
||||
err = sendSubConfirmEmail(app, c, ss.Email, es.ID, es.Token) |
||||
if err != nil { |
||||
log.Error("Failed to send subscription confirmation email: %s", err) |
||||
return err |
||||
} |
||||
} |
||||
|
||||
if ss.Web { |
||||
session, err := app.sessionStore.Get(r, userEmailCookieName) |
||||
if err != nil { |
||||
// The cookie should still save, even if there's an error.
|
||||
// Source: https://github.com/gorilla/sessions/issues/16#issuecomment-143642144
|
||||
log.Error("Getting user email cookie: %v; ignoring", err) |
||||
} |
||||
if confirmed { |
||||
addSessionFlash(app, w, r, "<strong>Subscribed</strong>. You'll now receive future blog posts via email.", nil) |
||||
} else { |
||||
addSessionFlash(app, w, r, "Please check your email and <strong>click the confirmation link</strong> to subscribe.", nil) |
||||
} |
||||
session.Values[userEmailCookieVal] = ss.Email |
||||
err = session.Save(r, w) |
||||
if err != nil { |
||||
log.Error("save email cookie: %s", err) |
||||
return err |
||||
} |
||||
|
||||
return impart.HTTPError{http.StatusFound, from} |
||||
} |
||||
return impart.WriteSuccess(w, "", http.StatusAccepted) |
||||
} |
||||
|
||||
func handleDeleteEmailSubscription(app *App, w http.ResponseWriter, r *http.Request) error { |
||||
alias := collectionAliasFromReq(r) |
||||
|
||||
vars := mux.Vars(r) |
||||
subID := vars["subscriber"] |
||||
email := r.FormValue("email") |
||||
token := r.FormValue("t") |
||||
slug := r.FormValue("slug") |
||||
isWeb := r.Method == "GET" |
||||
|
||||
// Display collection if this is a collection
|
||||
var c *Collection |
||||
var err error |
||||
if app.cfg.App.SingleUser { |
||||
c, err = app.db.GetCollectionByID(1) |
||||
} else { |
||||
c, err = app.db.GetCollection(alias) |
||||
} |
||||
if err != nil { |
||||
log.Error("Get collection: %s", err) |
||||
return err |
||||
} |
||||
|
||||
from := c.CanonicalURL() |
||||
|
||||
if subID != "" { |
||||
// User unsubscribing via email, so assume action is taken by either current
|
||||
// user or not current user, and only use the request's information to
|
||||
// satisfy this unsubscribe, i.e. subscriberID and token.
|
||||
err = app.db.DeleteEmailSubscriber(subID, token) |
||||
} else { |
||||
// User unsubscribing through the web app, so assume action is taken by
|
||||
// currently-auth'd user.
|
||||
var userID int64 |
||||
u := getUserSession(app, r) |
||||
if u != nil { |
||||
// User is logged in
|
||||
userID = u.ID |
||||
if userID == c.OwnerID { |
||||
from = "/" + c.Alias + "/" |
||||
} |
||||
} |
||||
if email == "" && userID <= 0 { |
||||
// Get email address from saved cookie
|
||||
session, err := app.sessionStore.Get(r, userEmailCookieName) |
||||
if err != nil { |
||||
log.Error("Unable to get email cookie: %s", err) |
||||
} else { |
||||
email = session.Values[userEmailCookieVal].(string) |
||||
} |
||||
} |
||||
|
||||
if email == "" && userID <= 0 { |
||||
err = fmt.Errorf("No subscriber given.") |
||||
log.Error("Not deleting subscription: %s", err) |
||||
return err |
||||
} |
||||
|
||||
err = app.db.DeleteEmailSubscriberByUser(email, userID, c.ID) |
||||
} |
||||
if err != nil { |
||||
log.Error("Unable to delete subscriber: %v", err) |
||||
return err |
||||
} |
||||
|
||||
if isWeb { |
||||
from += slug |
||||
addSessionFlash(app, w, r, "<strong>Unsubscribed</strong>. You will no longer receive these blog posts via email.", nil) |
||||
return impart.HTTPError{http.StatusFound, from} |
||||
} |
||||
return impart.WriteSuccess(w, "", http.StatusAccepted) |
||||
} |
||||
|
||||
func handleConfirmEmailSubscription(app *App, w http.ResponseWriter, r *http.Request) error { |
||||
alias := collectionAliasFromReq(r) |
||||
subID := mux.Vars(r)["subscriber"] |
||||
token := r.FormValue("t") |
||||
|
||||
var c *Collection |
||||
var err error |
||||
if app.cfg.App.SingleUser { |
||||
c, err = app.db.GetCollectionByID(1) |
||||
} else { |
||||
c, err = app.db.GetCollection(alias) |
||||
} |
||||
if err != nil { |
||||
log.Error("Get collection: %s", err) |
||||
return err |
||||
} |
||||
|
||||
from := c.CanonicalURL() |
||||
|
||||
err = app.db.UpdateSubscriberConfirmed(subID, token) |
||||
if err != nil { |
||||
addSessionFlash(app, w, r, err.Error(), nil) |
||||
return impart.HTTPError{http.StatusFound, from} |
||||
} |
||||
|
||||
addSessionFlash(app, w, r, "<strong>Confirmed</strong>! Thanks. Now you'll receive future blog posts via email.", nil) |
||||
return impart.HTTPError{http.StatusFound, from} |
||||
} |
||||
|
||||
func emailPost(app *App, p *PublicPost, collID int64) error { |
||||
p.augmentContent() |
||||
|
||||
// Do some shortcode replacement.
|
||||
// Since the user is receiving this email, we can assume they're subscribed via email.
|
||||
p.Content = strings.Replace(p.Content, "<!--emailsub-->", `<p id="emailsub">You're subscribed to email updates.</p>`, -1) |
||||
|
||||
if p.HTMLContent == template.HTML("") { |
||||
p.formatContent(app.cfg, false, false) |
||||
} |
||||
p.augmentReadingDestination() |
||||
|
||||
title := p.Title.String |
||||
if title != "" { |
||||
title = p.Title.String + "\n\n" |
||||
} |
||||
plainMsg := title + "A new post from " + p.CanonicalURL(app.cfg.App.Host) + "\n\n" + stripmd.Strip(p.Content) |
||||
plainMsg += ` |
||||
|
||||
--------------------------------------------------------------------------------- |
||||
|
||||
Originally published on ` + p.Collection.DisplayTitle() + ` (` + p.Collection.CanonicalURL() + `), a blog you subscribe to. |
||||
|
||||
Sent to %recipient.to%. Unsubscribe: ` + p.Collection.CanonicalURL() + `email/unsubscribe/%recipient.id%?t=%recipient.token%` |
||||
|
||||
gun := mailgun.NewMailgun(app.cfg.Email.Domain, app.cfg.Email.MailgunPrivate) |
||||
m := mailgun.NewMessage(p.Collection.DisplayTitle()+" <"+p.Collection.Alias+"@"+app.cfg.Email.Domain+">", stripmd.Strip(p.DisplayTitle()), plainMsg) |
||||
replyTo := app.db.GetCollectionAttribute(collID, collAttrLetterReplyTo) |
||||
if replyTo != "" { |
||||
m.SetReplyTo(replyTo) |
||||
} |
||||
|
||||
subs, err := app.db.GetEmailSubscribers(collID, true) |
||||
if err != nil { |
||||
log.Error("Unable to get email subscribers: %v", err) |
||||
return err |
||||
} |
||||
if len(subs) == 0 { |
||||
return nil |
||||
} |
||||
|
||||
if title != "" { |
||||
title = string(`<h2 id="title">` + p.FormattedDisplayTitle() + `</h2>`) |
||||
} |
||||
m.AddTag("New post") |
||||
|
||||
fontFam := "Lora, Palatino, Baskerville, serif" |
||||
if p.IsSans() { |
||||
fontFam = `"Open Sans", Tahoma, Arial, sans-serif` |
||||
} else if p.IsMonospace() { |
||||
fontFam = `Hack, consolas, Menlo-Regular, Menlo, Monaco, monospace, monospace` |
||||
} |
||||
|
||||
// TODO: move this to a templated file and LESS-generated stylesheet
|
||||
fullHTML := `<html> |
||||
<head> |
||||
<style> |
||||
body { |
||||
font-size: 120%; |
||||
font-family: ` + fontFam + `; |
||||
margin: 1em 2em; |
||||
} |
||||
#article { |
||||
line-height: 1.5; |
||||
margin: 1.5em 0; |
||||
white-space: pre-wrap; |
||||
word-wrap: break-word; |
||||
} |
||||
h1, h2, h3, h4, h5, h6, p, code { |
||||
display: inline |
||||
} |
||||
img, iframe, video { |
||||
max-width: 100% |
||||
} |
||||
#title { |
||||
margin-bottom: 1em; |
||||
display: block; |
||||
} |
||||
.intro { |
||||
font-style: italic; |
||||
font-size: 0.95em; |
||||
} |
||||
div#footer { |
||||
text-align: center; |
||||
max-width: 35em; |
||||
margin: 2em auto; |
||||
} |
||||
div#footer p { |
||||
display: block; |
||||
font-size: 0.86em; |
||||
color: #666; |
||||
} |
||||
hr { |
||||
border: 1px solid #ccc; |
||||
margin: 2em 1em; |
||||
} |
||||
p#emailsub { |
||||
text-align: center; |
||||
display: inline-block !important; |
||||
width: 100%; |
||||
font-style: italic; |
||||
} |
||||
</style> |
||||
</head> |
||||
<body> |
||||
<div id="article">` + title + `<p class="intro">From <a href="` + p.CanonicalURL(app.cfg.App.Host) + `">` + p.DisplayCanonicalURL() + `</a></p> |
||||
|
||||
` + string(p.HTMLContent) + `</div> |
||||
<hr /> |
||||
<div id="footer"> |
||||
<p>Originally published on <a href="` + p.Collection.CanonicalURL() + `">` + p.Collection.DisplayTitle() + `</a>, a blog you subscribe to.</p> |
||||
<p>Sent to %recipient.to%. <a href="` + p.Collection.CanonicalURL() + `email/unsubscribe/%recipient.id%?t=%recipient.token%">Unsubscribe</a>.</p> |
||||
</div> |
||||
</body> |
||||
</html>` |
||||
|
||||
// inline CSS
|
||||
html, err := inliner.Inline(fullHTML) |
||||
if err != nil { |
||||
log.Error("Unable to inline email HTML: %v", err) |
||||
return err |
||||
} |
||||
|
||||
m.SetHtml(html) |
||||
|
||||
log.Info("[email] Adding %d recipient(s)", len(subs)) |
||||
for _, s := range subs { |
||||
e := s.FinalEmail(app.keys) |
||||
log.Info("[email] Adding %s", e) |
||||
err = m.AddRecipientAndVariables(e, map[string]interface{}{ |
||||
"id": s.ID, |
||||
"to": e, |
||||
"token": s.Token, |
||||
}) |
||||
if err != nil { |
||||
log.Error("Unable to add receipient %s: %s", e, err) |
||||
} |
||||
} |
||||
|
||||
res, _, err := gun.Send(m) |
||||
log.Info("[email] Send result: %s", res) |
||||
if err != nil { |
||||
log.Error("Unable to send post email: %v", err) |
||||
return err |
||||
} |
||||
|
||||
return nil |
||||
} |
||||
|
||||
func sendSubConfirmEmail(app *App, c *Collection, email, subID, token string) error { |
||||
if email == "" { |
||||
return fmt.Errorf("You must supply an email to verify.") |
||||
} |
||||
|
||||
// Send email
|
||||
gun := mailgun.NewMailgun(app.cfg.Email.Domain, app.cfg.Email.MailgunPrivate) |
||||
|
||||
plainMsg := "Confirm your subscription to " + c.DisplayTitle() + ` (` + c.CanonicalURL() + `) to start receiving future posts. Simply click the following link (or copy and paste it into your browser): |
||||
|
||||
` + c.CanonicalURL() + "email/confirm/" + subID + "?t=" + token + ` |
||||
|
||||
If you didn't subscribe to this site or you're not sure why you're getting this email, you can delete it. You won't be subscribed or receive any future emails.` |
||||
m := mailgun.NewMessage(c.DisplayTitle()+" <"+c.Alias+"@"+app.cfg.Email.Domain+">", "Confirm your subscription to "+c.DisplayTitle(), plainMsg, fmt.Sprintf("<%s>", email)) |
||||
m.AddTag("Email Verification") |
||||
|
||||
m.SetHtml(`<html> |
||||
<body style="font-family:Lora, 'Palatino Linotype', Palatino, Baskerville, 'Book Antiqua', 'New York', 'DejaVu serif', serif; font-size: 100%%; margin:1em 2em;"> |
||||
<div style="font-size: 1.2em;"> |
||||
<p>Confirm your subscription to <a href="` + c.CanonicalURL() + `">` + c.DisplayTitle() + `</a> to start receiving future posts:</p> |
||||
<p><a href="` + c.CanonicalURL() + `email/confirm/` + subID + `?t=` + token + `">Subscribe to ` + c.DisplayTitle() + `</a></p> |
||||
<p>If you didn't subscribe to this site or you're not sure why you're getting this email, you can delete it. You won't be subscribed or receive any future emails.</p> |
||||
</div> |
||||
</body> |
||||
</html>`) |
||||
gun.Send(m) |
||||
|
||||
return nil |
||||
} |
@ -0,0 +1,72 @@ |
||||
package writefreely |
||||
|
||||
import ( |
||||
"github.com/writeas/web-core/log" |
||||
"time" |
||||
) |
||||
|
||||
type PostJob struct { |
||||
ID int64 |
||||
PostID string |
||||
Action string |
||||
Delay int64 |
||||
} |
||||
|
||||
func addJob(app *App, p *PublicPost, action string, delay int64) error { |
||||
j := &PostJob{ |
||||
PostID: p.ID, |
||||
Action: action, |
||||
Delay: delay, |
||||
} |
||||
return app.db.InsertJob(j) |
||||
} |
||||
|
||||
func startPublishJobsQueue(app *App) { |
||||
t := time.NewTicker(62 * time.Second) |
||||
for { |
||||
log.Info("[jobs] Done.") |
||||
<-t.C |
||||
log.Info("[jobs] Fetching email publish jobs...") |
||||
jobs, err := app.db.GetJobsToRun("email") |
||||
if err != nil { |
||||
log.Error("[jobs] %s - Skipping.", err) |
||||
continue |
||||
} |
||||
log.Info("[jobs] Running %d email publish jobs...", len(jobs)) |
||||
err = runJobs(app, jobs, true) |
||||
if err != nil { |
||||
log.Error("[jobs] Failed: %s", err) |
||||
} |
||||
} |
||||
} |
||||
|
||||
func runJobs(app *App, jobs []*PostJob, reqColl bool) error { |
||||
for _, j := range jobs { |
||||
p, err := app.db.GetPost(j.PostID, 0) |
||||
if err != nil { |
||||
log.Info("[job #%d] Unable to get post: %s", j.ID, err) |
||||
continue |
||||
} |
||||
if !p.CollectionID.Valid && reqColl { |
||||
log.Info("[job #%d] Post %s not part of a collection", j.ID, p.ID) |
||||
app.db.DeleteJob(j.ID) |
||||
continue |
||||
} |
||||
coll, err := app.db.GetCollectionByID(p.CollectionID.Int64) |
||||
if err != nil { |
||||
log.Info("[job #%d] Unable to get collection: %s", j.ID, err) |
||||
continue |
||||
} |
||||
coll.hostName = app.cfg.App.Host |
||||
coll.ForPublic() |
||||
p.Collection = &CollectionObj{Collection: *coll} |
||||
err = emailPost(app, p, p.Collection.ID) |
||||
if err != nil { |
||||
log.Error("[job #%d] Failed to email post %s", j.ID, p.ID) |
||||
continue |
||||
} |
||||
log.Info("[job #%d] Success for post %s.", j.ID, p.ID) |
||||
app.db.DeleteJob(j.ID) |
||||
} |
||||
return nil |
||||
} |
@ -0,0 +1,58 @@ |
||||
/* |
||||
* Copyright © 2021 Musing Studio LLC. |
||||
* |
||||
* This file is part of WriteFreely. |
||||
* |
||||
* WriteFreely is free software: you can redistribute it and/or modify |
||||
* it under the terms of the GNU Affero General Public License, included |
||||
* in the LICENSE file in this source code package. |
||||
*/ |
||||
|
||||
package migrations |
||||
|
||||
func supportLetters(db *datastore) error { |
||||
t, err := db.Begin() |
||||
if err != nil { |
||||
t.Rollback() |
||||
return err |
||||
} |
||||
|
||||
_, err = t.Exec(`CREATE TABLE publishjobs ( |
||||
id ` + db.typeIntPrimaryKey() + `, |
||||
post_id ` + db.typeVarChar(16) + ` not null, |
||||
action ` + db.typeVarChar(16) + ` not null, |
||||
delay ` + db.typeTinyInt() + ` not null |
||||
)`) |
||||
if err != nil { |
||||
t.Rollback() |
||||
return err |
||||
} |
||||
|
||||
_, err = t.Exec(`CREATE TABLE emailsubscribers ( |
||||
id ` + db.typeChar(8) + ` not null, |
||||
collection_id ` + db.typeInt() + ` not null, |
||||
user_id ` + db.typeInt() + ` null, |
||||
email ` + db.typeVarChar(255) + ` null, |
||||
subscribed ` + db.typeDateTime() + ` not null, |
||||
token ` + db.typeChar(16) + ` not null, |
||||
confirmed ` + db.typeBool() + ` default 0 not null, |
||||
allow_export ` + db.typeBool() + ` default 0 not null, |
||||
constraint eu_coll_email |
||||
unique (collection_id, email), |
||||
constraint eu_coll_user |
||||
unique (collection_id, user_id), |
||||
PRIMARY KEY (id) |
||||
)`) |
||||
if err != nil { |
||||
t.Rollback() |
||||
return err |
||||
} |
||||
|
||||
err = t.Commit() |
||||
if err != nil { |
||||
t.Rollback() |
||||
return err |
||||
} |
||||
|
||||
return nil |
||||
} |
@ -0,0 +1,43 @@ |
||||
/* |
||||
* Copyright © 2020-2021 Musing Studio LLC. |
||||
* |
||||
* This file is part of WriteFreely. |
||||
* |
||||
* WriteFreely is free software: you can redistribute it and/or modify |
||||
* it under the terms of the GNU Affero General Public License, included |
||||
* in the LICENSE file in this source code package. |
||||
*/ |
||||
|
||||
package spam |
||||
|
||||
import ( |
||||
"github.com/writeas/web-core/id" |
||||
"strings" |
||||
) |
||||
|
||||
var honeypotField string |
||||
|
||||
func HoneypotFieldName() string { |
||||
if honeypotField == "" { |
||||
honeypotField = id.Generate62RandomString(39) |
||||
} |
||||
return honeypotField |
||||
} |
||||
|
||||
// CleanEmail takes an email address and strips it down to a unique address that can be blocked.
|
||||
func CleanEmail(email string) string { |
||||
emailParts := strings.Split(strings.ToLower(email), "@") |
||||
if len(emailParts) < 2 { |
||||
return "" |
||||
} |
||||
u := emailParts[0] |
||||
d := emailParts[1] |
||||
// Ignore anything after '+'
|
||||
plusIdx := strings.IndexRune(u, '+') |
||||
if plusIdx > -1 { |
||||
u = u[:plusIdx] |
||||
} |
||||
// Strip dots in email address
|
||||
u = strings.ReplaceAll(u, ".", "") |
||||
return u + "@" + d |
||||
} |
Loading…
Reference in new issue