Skip to content

Commit d8c662c

Browse files
committed
Send email when a message fails or is expired
1 parent 7ec17b3 commit d8c662c

8 files changed

Lines changed: 233 additions & 5 deletions

File tree

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
package emails
2+
3+
import (
4+
"fmt"
5+
"time"
6+
7+
"github.com/google/uuid"
8+
9+
"github.com/NdoleStudio/httpsms/pkg/entities"
10+
"github.com/matcornic/hermes/v2"
11+
"github.com/palantir/stacktrace"
12+
)
13+
14+
type hermesNotificationEmailFactory struct {
15+
config *HermesGeneratorConfig
16+
generator hermes.Hermes
17+
}
18+
19+
// NewHermesNotificationEmailFactory creates a new instance of the UserEmailFactory
20+
func NewHermesNotificationEmailFactory(config *HermesGeneratorConfig) NotificationEmailFactory {
21+
return &hermesNotificationEmailFactory{
22+
config: config,
23+
generator: config.Generator(),
24+
}
25+
}
26+
27+
func (factory *hermesNotificationEmailFactory) MessageExpired(user *entities.User, messageID uuid.UUID, owner string, contact string, content string) (*Email, error) {
28+
email := hermes.Email{
29+
Body: hermes.Body{
30+
Title: "Hello",
31+
Intros: []string{
32+
fmt.Sprintf("The SMS message which you sent to %s has expired at %s and you will need to resend this message.", owner, user.UserTimeString(time.Now())),
33+
},
34+
Dictionary: []hermes.Entry{
35+
{"ID", messageID.String()},
36+
{"From", owner},
37+
{"To", contact},
38+
{"Message", content},
39+
},
40+
Actions: []hermes.Action{
41+
{
42+
Instructions: "Messages expire because we couldn't connect with your mobile phone to send the outgoing SMS. You can fix this by making sure your phone is connected to the internet and also connect your phone to the charger all the time since Android may kill the httpSMS app if it has been active for a very long time so save phone battery.",
43+
Button: hermes.Button{
44+
Color: "#329ef4",
45+
TextColor: "#FFFFFF",
46+
Text: "View Messages",
47+
Link: "https://httpsms.com/threads",
48+
},
49+
},
50+
},
51+
Signature: "Cheers",
52+
Outros: []string{
53+
fmt.Sprintf("Don't hesitate to contact us by replying to this email."),
54+
},
55+
},
56+
}
57+
58+
html, err := factory.generator.GenerateHTML(email)
59+
if err != nil {
60+
return nil, stacktrace.Propagate(err, "cannot generate html email")
61+
}
62+
63+
text, err := factory.generator.GeneratePlainText(email)
64+
if err != nil {
65+
return nil, stacktrace.Propagate(err, "cannot generate text email")
66+
}
67+
68+
return &Email{
69+
ToEmail: user.Email,
70+
Subject: "🔔 Your SMS message has expired on httpSMS",
71+
HTML: html,
72+
Text: text,
73+
}, nil
74+
}
75+
76+
func (factory *hermesNotificationEmailFactory) MessageFailed(user *entities.User, messageID uuid.UUID, owner, contact, content, reason string) (*Email, error) {
77+
email := hermes.Email{
78+
Body: hermes.Body{
79+
Title: "Hello",
80+
Intros: []string{
81+
fmt.Sprintf("The SMS message which you sent to %s has failed at %s and you will need to resend this message.", owner, user.UserTimeString(time.Now())),
82+
},
83+
Dictionary: []hermes.Entry{
84+
{"ID", messageID.String()},
85+
{"From", owner},
86+
{"To", contact},
87+
{"Message", content},
88+
{"Failure Reason", reason},
89+
},
90+
Actions: []hermes.Action{
91+
{
92+
Instructions: "Check the default SMS messaging app on your phone to find out the exact reason why the message failed. Usually messages fail because the httpSMS app phone has been un-installed or it is not active. Logout and login again on the mobile app on your Android phone and retry sending the SMS.",
93+
Button: hermes.Button{
94+
Color: "#329ef4",
95+
TextColor: "#FFFFFF",
96+
Text: "View Messages",
97+
Link: "https://httpsms.com/threads",
98+
},
99+
},
100+
},
101+
Signature: "Cheers",
102+
Outros: []string{
103+
fmt.Sprintf("Don't hesitate to contact us by replying to this email."),
104+
},
105+
},
106+
}
107+
108+
html, err := factory.generator.GenerateHTML(email)
109+
if err != nil {
110+
return nil, stacktrace.Propagate(err, "cannot generate html email")
111+
}
112+
113+
text, err := factory.generator.GeneratePlainText(email)
114+
if err != nil {
115+
return nil, stacktrace.Propagate(err, "cannot generate text email")
116+
}
117+
118+
return &Email{
119+
ToEmail: user.Email,
120+
Subject: "⚡ Your SMS message has failed on httpSMS",
121+
HTML: html,
122+
Text: text,
123+
}, nil
124+
}
Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,15 @@
11
package emails
22

3+
import (
4+
"github.com/NdoleStudio/httpsms/pkg/entities"
5+
"github.com/google/uuid"
6+
)
7+
38
// NotificationEmailFactory generates emails to users about a message
4-
type NotificationEmailFactory interface{}
9+
type NotificationEmailFactory interface {
10+
// MessageExpired sends an email when the user's message is expired
11+
MessageExpired(user *entities.User, messageID uuid.UUID, owner, contact, content string) (*Email, error)
12+
13+
// MessageFailed sends an email when the user's message is failed
14+
MessageFailed(user *entities.User, messageID uuid.UUID, owner, contact, content, reason string) (*Email, error)
15+
}

api/pkg/entities/user.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,3 +80,12 @@ func (user User) IsOnProPlan() bool {
8080
func (user User) IsOnUltraPlan() bool {
8181
return user.SubscriptionName == SubscriptionNameUltraMonthly || user.SubscriptionName == SubscriptionNameUltraYearly
8282
}
83+
84+
// UserTimeString converts the time to the user's timezone
85+
func (user User) UserTimeString(timestamp time.Time) string {
86+
location, err := time.LoadLocation(user.Timezone)
87+
if err != nil {
88+
location = time.UTC
89+
}
90+
return timestamp.In(location).Format(time.RFC1123)
91+
}

api/pkg/events/message_send_expired_event.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ type MessageSendExpiredPayload struct {
1616
MessageID uuid.UUID `json:"message_id"`
1717
Owner string `json:"owner"`
1818
SendAttemptCount uint `json:"send_attempt_count"`
19+
IsFinal bool `json:"is_final"`
1920
RequestID *string `json:"request_id"`
2021
Contact string `json:"contact"`
2122
UserID entities.UserID `json:"user_id"`

api/pkg/repositories/gorm_user_repository.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -187,9 +187,8 @@ func (repository *gormUserRepository) LoadOrStore(ctx context.Context, authUser
187187
// case the caller should not continue.
188188
func (repository *gormUserRepository) generateRandomBytes(n int) ([]byte, error) {
189189
b := make([]byte, n)
190-
_, err := rand.Read(b)
191190
// Note that err == nil only if we read len(b) bytes.
192-
if err != nil {
191+
if _, err := rand.Read(b); err != nil {
193192
return nil, stacktrace.Propagate(err, fmt.Sprintf("cannot generate [%d] random bytes", n))
194193
}
195194

api/pkg/services/billing_service.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,11 +111,11 @@ func (service *BillingService) sendLimitExceededEmail(ctx context.Context, user
111111
if err = service.mailer.Send(ctx, email); err != nil {
112112
msg := fmt.Sprintf("canot send usage limit exceeded notification to user [%s]", user.ID)
113113
ctxLogger.Error(stacktrace.Propagate(err, msg))
114+
return
114115
}
115116

116117
ctxLogger.Info(fmt.Sprintf("usage limit exceeded email sent to user [%s]", user.ID))
117-
118-
if err = service.cache.Set(ctx, key, "", time.Hour); err != nil {
118+
if err = service.cache.Set(ctx, key, "", time.Hour*12); err != nil {
119119
ctxLogger.Error(stacktrace.Propagate(err, fmt.Sprintf("cannot set item in redis with key [%s]", key)))
120120
}
121121
}

api/pkg/services/email_notification_service.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ package services
33
import (
44
"context"
55
"fmt"
6+
"time"
7+
8+
"github.com/palantir/stacktrace"
69

710
"github.com/NdoleStudio/httpsms/pkg/cache"
811
"github.com/NdoleStudio/httpsms/pkg/emails"
@@ -43,10 +46,90 @@ func NewEmailNotificationService(
4346

4447
// NotifyMessageExpired sends an email to the user about an expired message
4548
func (service *EmailNotificationService) NotifyMessageExpired(ctx context.Context, payload *events.MessageSendExpiredPayload) error {
49+
ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger)
50+
defer span.End()
51+
52+
if !payload.IsFinal {
53+
ctxLogger.Info(fmt.Sprintf("[%s] event is not final, send attempt count = [%d]", events.EventTypeMessageSendExpired, payload.SendAttemptCount))
54+
return nil
55+
}
56+
57+
if !service.canSendEmail(ctx, events.EventTypeMessageSendExpired, payload.Owner) {
58+
ctxLogger.Info(fmt.Sprintf("[%s] email already sent to user [%s] with owner [%s]", events.EventTypeMessageSendExpired, payload.UserID, payload.Owner))
59+
return nil
60+
}
61+
62+
user, err := service.userRepository.Load(ctx, payload.UserID)
63+
if err != nil {
64+
msg := fmt.Sprintf("cannot load user with ID [%s] and for expired message with ID [%s]", payload.UserID, payload.MessageID)
65+
return service.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, stacktrace.GetCode(err), msg))
66+
}
67+
68+
email, err := service.factory.MessageExpired(user, payload.MessageID, payload.Owner, payload.Contact, payload.Content)
69+
if err != nil {
70+
msg := fmt.Sprintf("cannot create email for user with ID [%s] and for expired message with ID [%s]", payload.UserID, payload.MessageID)
71+
return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))
72+
}
73+
74+
if err = service.mailer.Send(ctx, email); err != nil {
75+
msg := fmt.Sprintf("cannot send email for user with ID [%s] and for expired message with ID [%s]", payload.UserID, payload.MessageID)
76+
return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))
77+
}
78+
79+
ctxLogger.Info(fmt.Sprintf("[%s] email sent to [%s] for message with ID [%s]", events.EventTypeMessageSendExpired, user.ID, payload.MessageID))
80+
81+
service.addToCache(ctx, events.EventTypeMessageSendExpired, payload.Owner)
4682
return nil
4783
}
4884

4985
// NotifyMessageFailed sends an email to the user about a failed message
5086
func (service *EmailNotificationService) NotifyMessageFailed(ctx context.Context, payload *events.MessageSendFailedPayload) error {
87+
ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger)
88+
defer span.End()
89+
90+
if !service.canSendEmail(ctx, events.EventTypeMessageSendFailed, payload.Owner) {
91+
ctxLogger.Info(fmt.Sprintf("[%s] email already sent to user [%s] with owner [%s]", events.EventTypeMessageSendFailed, payload.UserID, payload.Owner))
92+
return nil
93+
}
94+
95+
user, err := service.userRepository.Load(ctx, payload.UserID)
96+
if err != nil {
97+
msg := fmt.Sprintf("cannot load user with ID [%s] for [%s] message with ID [%s]", payload.UserID, payload.ID)
98+
return service.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, stacktrace.GetCode(err), msg))
99+
}
100+
101+
email, err := service.factory.MessageFailed(user, payload.ID, payload.Owner, payload.Contact, payload.Content, payload.ErrorMessage)
102+
if err != nil {
103+
msg := fmt.Sprintf("cannot create email for user with ID [%s] for [%s] message with ID [%s]", payload.UserID, payload.ID)
104+
return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))
105+
}
106+
107+
if err = service.mailer.Send(ctx, email); err != nil {
108+
msg := fmt.Sprintf("cannot send email for user with ID [%s] for [%s] message with ID [%s]", payload.UserID, events.EventTypeMessageSendFailed, payload.ID)
109+
return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))
110+
}
111+
112+
ctxLogger.Info(fmt.Sprintf("[%s] email sent to [%s] for message with ID [%s]", events.EventTypeMessageSendFailed, user.ID, payload.ID))
113+
114+
service.addToCache(ctx, events.EventTypeMessageSendFailed, payload.Owner)
51115
return nil
52116
}
117+
118+
func (service *EmailNotificationService) getCacheKey(event string, owner string) string {
119+
return fmt.Sprintf("email.%s.%s", event, owner)
120+
}
121+
122+
func (service *EmailNotificationService) canSendEmail(ctx context.Context, event string, owner string) bool {
123+
_, err := service.cache.Get(ctx, service.getCacheKey(event, owner))
124+
return err != nil
125+
}
126+
127+
func (service *EmailNotificationService) addToCache(ctx context.Context, event string, owner string) {
128+
ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger)
129+
defer span.End()
130+
131+
cacheKey := service.getCacheKey(event, owner)
132+
if err := service.cache.Set(ctx, cacheKey, "", time.Minute*15); err != nil {
133+
ctxLogger.Error(stacktrace.Propagate(err, fmt.Sprintf("cannot set item in redis with key [%s] for owner [%s]", cacheKey, owner)))
134+
}
135+
}

api/pkg/services/message_service.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -672,6 +672,7 @@ func (service *MessageService) CheckExpired(ctx context.Context, params MessageC
672672
Owner: message.Owner,
673673
Contact: message.Contact,
674674
RequestID: message.RequestID,
675+
IsFinal: message.SendAttemptCount == message.MaxSendAttempts,
675676
SendAttemptCount: message.SendAttemptCount,
676677
UserID: message.UserID,
677678
Timestamp: time.Now().UTC(),

0 commit comments

Comments
 (0)