feat: send email notifications for missed messages

- New cron job in cluster
- Handle presence and other concerns
- Toggle in app to disable email notifications
- Handles other edge cases such as cooldown period
- Simple html email with simple message

Closes #117
This commit is contained in:
talksik
2026-04-09 14:15:34 -07:00
parent 6fe5af8d8d
commit 51c4c6c822
25 changed files with 725 additions and 29 deletions
+6 -4
View File
@@ -3,8 +3,10 @@ package human
import "time"
type Human struct {
ID string
Email string
EmailPrefix string
CreatedAt time.Time
ID string
Email string
EmailPrefix string
EmailNotificationsEnabled bool
LastEmailNotificationSentAt *time.Time
CreatedAt time.Time
}
+59 -6
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
@@ -33,6 +34,9 @@ type repository interface {
getByID(ctx context.Context, id string) (*Human, error)
create(ctx context.Context, email string) (*Human, error)
exists(ctx context.Context, email string) (bool, error)
listAll(ctx context.Context) ([]*Human, error)
updateEmailNotificationsEnabled(ctx context.Context, id string, enabled bool) error
updateLastEmailNotificationSentAt(ctx context.Context, id string, t time.Time) error
}
type repositoryImpl struct {
@@ -46,9 +50,9 @@ func newRepository(pool *pgxpool.Pool) repository {
func (r *repositoryImpl) getByEmail(ctx context.Context, email string) (*Human, error) {
var h Human
err := r.pool.QueryRow(ctx,
`SELECT id, email, created_at FROM humans WHERE email = $1`,
`SELECT id, email, email_notifications_enabled, last_email_notification_sent_at, created_at FROM humans WHERE email = $1`,
email,
).Scan(&h.ID, &h.Email, &h.CreatedAt)
).Scan(&h.ID, &h.Email, &h.EmailNotificationsEnabled, &h.LastEmailNotificationSentAt, &h.CreatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, errNotFound
@@ -62,9 +66,9 @@ func (r *repositoryImpl) getByEmail(ctx context.Context, email string) (*Human,
func (r *repositoryImpl) getByID(ctx context.Context, id string) (*Human, error) {
var h Human
err := r.pool.QueryRow(ctx,
`SELECT id, email, created_at FROM humans WHERE id = $1`,
`SELECT id, email, email_notifications_enabled, last_email_notification_sent_at, created_at FROM humans WHERE id = $1`,
id,
).Scan(&h.ID, &h.Email, &h.CreatedAt)
).Scan(&h.ID, &h.Email, &h.EmailNotificationsEnabled, &h.LastEmailNotificationSentAt, &h.CreatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, errNotFound
@@ -84,9 +88,9 @@ func (r *repositoryImpl) create(ctx context.Context, email string) (*Human, erro
var h Human
err = r.pool.QueryRow(ctx,
`INSERT INTO humans (id, email) VALUES ($1, $2)
RETURNING id, email, created_at`,
RETURNING id, email, email_notifications_enabled, last_email_notification_sent_at, created_at`,
id.String(), email,
).Scan(&h.ID, &h.Email, &h.CreatedAt)
).Scan(&h.ID, &h.Email, &h.EmailNotificationsEnabled, &h.LastEmailNotificationSentAt, &h.CreatedAt)
if err != nil {
return nil, err
}
@@ -106,3 +110,52 @@ func (r *repositoryImpl) exists(ctx context.Context, email string) (bool, error)
}
return exists, nil
}
func (r *repositoryImpl) listAll(ctx context.Context) ([]*Human, error) {
rows, err := r.pool.Query(ctx,
`SELECT id, email, email_notifications_enabled, last_email_notification_sent_at, created_at FROM humans`,
)
if err != nil {
return nil, err
}
defer rows.Close()
var humans []*Human
for rows.Next() {
var h Human
if err := rows.Scan(&h.ID, &h.Email, &h.EmailNotificationsEnabled, &h.LastEmailNotificationSentAt, &h.CreatedAt); err != nil {
return nil, err
}
h.EmailPrefix = emailPrefix(h.Email)
humans = append(humans, &h)
}
return humans, rows.Err()
}
func (r *repositoryImpl) updateEmailNotificationsEnabled(ctx context.Context, id string, enabled bool) error {
result, err := r.pool.Exec(ctx,
`UPDATE humans SET email_notifications_enabled = $2 WHERE id = $1`,
id, enabled,
)
if err != nil {
return err
}
if result.RowsAffected() == 0 {
return errNotFound
}
return nil
}
func (r *repositoryImpl) updateLastEmailNotificationSentAt(ctx context.Context, id string, t time.Time) error {
result, err := r.pool.Exec(ctx,
`UPDATE humans SET last_email_notification_sent_at = $2 WHERE id = $1`,
id, t,
)
if err != nil {
return err
}
if result.RowsAffected() == 0 {
return errNotFound
}
return nil
}
+27
View File
@@ -3,6 +3,7 @@ package human
import (
"context"
"errors"
"time"
"github.com/flowy-live/llink/internal/utils"
"github.com/jackc/pgx/v5/pgxpool"
@@ -16,6 +17,12 @@ type Service interface {
GetByEmail(ctx context.Context, email string) (*Human, error)
// GetByID returns ErrNotFound if no human found
GetByID(ctx context.Context, id string) (*Human, error)
// ListAll returns all humans
ListAll(ctx context.Context) ([]*Human, error)
// UpdateEmailNotificationsEnabled toggles email notification preference
UpdateEmailNotificationsEnabled(ctx context.Context, id string, enabled bool) error
// UpdateLastEmailNotificationSentAt records when the last notification email was sent
UpdateLastEmailNotificationSentAt(ctx context.Context, id string, t time.Time) error
}
type serviceImpl struct {
@@ -62,3 +69,23 @@ func (s *serviceImpl) GetByID(ctx context.Context, id string) (*Human, error) {
}
return h, err
}
func (s *serviceImpl) ListAll(ctx context.Context) ([]*Human, error) {
return s.repo.listAll(ctx)
}
func (s *serviceImpl) UpdateEmailNotificationsEnabled(ctx context.Context, id string, enabled bool) error {
err := s.repo.updateEmailNotificationsEnabled(ctx, id, enabled)
if errors.Is(err, errNotFound) {
return ErrNotFound
}
return err
}
func (s *serviceImpl) UpdateLastEmailNotificationSentAt(ctx context.Context, id string, t time.Time) error {
err := s.repo.updateLastEmailNotificationSentAt(ctx, id, t)
if errors.Is(err, errNotFound) {
return ErrNotFound
}
return err
}