mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-06-03 20:13:00 +00:00
cleanup
This commit is contained in:
@@ -0,0 +1,354 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"bookra/apps/backend/internal/db"
|
||||
)
|
||||
|
||||
type EmailType string
|
||||
|
||||
const (
|
||||
EmailTypeConfirmation EmailType = "confirmation"
|
||||
EmailTypeReminder EmailType = "reminder"
|
||||
EmailTypeReschedule EmailType = "reschedule"
|
||||
EmailTypeCancellation EmailType = "cancellation"
|
||||
EmailTypeBusinessNotify EmailType = "business_notify"
|
||||
)
|
||||
|
||||
type BookingEmailData struct {
|
||||
Type EmailType
|
||||
TenantName string
|
||||
TenantSlug string
|
||||
BusinessEmail string
|
||||
BusinessPhone string
|
||||
BusinessAddress string
|
||||
BrandColor string
|
||||
CustomerName string
|
||||
CustomerEmail string
|
||||
Service string
|
||||
Location string
|
||||
Reference string
|
||||
StartsAt time.Time
|
||||
EndsAt time.Time
|
||||
Timezone string
|
||||
Locale string
|
||||
Notes string
|
||||
ManagementURL string
|
||||
AddToCalendarURL string
|
||||
}
|
||||
|
||||
func RenderEmailMessage(data BookingEmailData) EmailMessage {
|
||||
subject := renderSubject(data)
|
||||
htmlBody := renderHTMLBody(data)
|
||||
textBody := renderTextBody(data)
|
||||
|
||||
return EmailMessage{
|
||||
From: data.BusinessEmail,
|
||||
To: data.CustomerEmail,
|
||||
Subject: subject,
|
||||
Text: textBody,
|
||||
HTML: htmlBody,
|
||||
}
|
||||
}
|
||||
|
||||
func renderSubject(data BookingEmailData) string {
|
||||
localizedTime := formatLocalizedTime(data.StartsAt, data.Timezone, data.Locale)
|
||||
|
||||
switch data.Type {
|
||||
case EmailTypeConfirmation:
|
||||
if data.Locale == "cs" {
|
||||
return fmt.Sprintf("Potvrzení rezervace %s - %s", data.Reference, data.TenantName)
|
||||
}
|
||||
return fmt.Sprintf("Booking Confirmation %s - %s", data.Reference, data.TenantName)
|
||||
case EmailTypeReminder:
|
||||
if data.Locale == "cs" {
|
||||
return fmt.Sprintf("Připomínka: Máte rezervaci zítra v %s", localizedTime)
|
||||
}
|
||||
return fmt.Sprintf("Reminder: You have a booking tomorrow at %s", localizedTime)
|
||||
case EmailTypeReschedule:
|
||||
if data.Locale == "cs" {
|
||||
return fmt.Sprintf("Vaše rezervace byla přesunuta - %s", data.Reference)
|
||||
}
|
||||
return fmt.Sprintf("Your booking has been rescheduled - %s", data.Reference)
|
||||
case EmailTypeCancellation:
|
||||
if data.Locale == "cs" {
|
||||
return fmt.Sprintf("Vaše rezervace byla zrušena - %s", data.Reference)
|
||||
}
|
||||
return fmt.Sprintf("Your booking has been cancelled - %s", data.Reference)
|
||||
case EmailTypeBusinessNotify:
|
||||
if data.Locale == "cs" {
|
||||
return fmt.Sprintf("Nová rezervace od %s - %s", data.CustomerName, data.Reference)
|
||||
}
|
||||
return fmt.Sprintf("New booking from %s - %s", data.CustomerName, data.Reference)
|
||||
default:
|
||||
return "Booking Update"
|
||||
}
|
||||
}
|
||||
|
||||
func renderTextBody(data BookingEmailData) string {
|
||||
localizedTime := formatLocalizedDateTime(data.StartsAt, data.Timezone, data.Locale)
|
||||
|
||||
switch data.Type {
|
||||
case EmailTypeConfirmation:
|
||||
if data.Locale == "cs" {
|
||||
return fmt.Sprintf(`Dobrý den %s,
|
||||
|
||||
vaše rezervace byla potvrzena.
|
||||
|
||||
Detaily rezervace:
|
||||
- Služba: %s
|
||||
- Datum a čas: %s
|
||||
- Místo: %s
|
||||
- Reference: %s
|
||||
|
||||
Pro správu rezervace navštivte: %s
|
||||
|
||||
Děkujeme,
|
||||
%s
|
||||
%s`, data.CustomerName, data.Service, localizedTime, data.Location, data.Reference, data.ManagementURL, data.TenantName, data.BusinessEmail)
|
||||
}
|
||||
return fmt.Sprintf(`Hello %s,
|
||||
|
||||
Your booking has been confirmed.
|
||||
|
||||
Booking Details:
|
||||
- Service: %s
|
||||
- Date & Time: %s
|
||||
- Location: %s
|
||||
- Reference: %s
|
||||
|
||||
Manage your booking at: %s
|
||||
|
||||
Thank you,
|
||||
%s
|
||||
%s`, data.CustomerName, data.Service, localizedTime, data.Location, data.Reference, data.ManagementURL, data.TenantName, data.BusinessEmail)
|
||||
|
||||
case EmailTypeReminder:
|
||||
if data.Locale == "cs" {
|
||||
return fmt.Sprintf(`Dobrý den %s,
|
||||
|
||||
připomínáme vám zítřejší rezervaci.
|
||||
|
||||
- Služba: %s
|
||||
- Čas: %s
|
||||
- Místo: %s
|
||||
- Reference: %s
|
||||
|
||||
Pro správu rezervace: %s
|
||||
|
||||
%s`, data.CustomerName, data.Service, localizedTime, data.Location, data.Reference, data.ManagementURL, data.TenantName)
|
||||
}
|
||||
return fmt.Sprintf(`Hello %s,
|
||||
|
||||
This is a reminder for your booking tomorrow.
|
||||
|
||||
- Service: %s
|
||||
- Time: %s
|
||||
- Location: %s
|
||||
- Reference: %s
|
||||
|
||||
Manage booking: %s
|
||||
|
||||
%s`, data.CustomerName, data.Service, localizedTime, data.Location, data.Reference, data.ManagementURL, data.TenantName)
|
||||
|
||||
case EmailTypeReschedule:
|
||||
if data.Locale == "cs" {
|
||||
return fmt.Sprintf(`Dobrý den %s,
|
||||
|
||||
vaše rezervace byla přesunuta na nový termín.
|
||||
|
||||
Nové detaily:
|
||||
- Služba: %s
|
||||
- Datum a čas: %s
|
||||
- Místo: %s
|
||||
- Reference: %s
|
||||
|
||||
Pro správu rezervace: %s
|
||||
|
||||
%s`, data.CustomerName, data.Service, localizedTime, data.Location, data.Reference, data.ManagementURL, data.TenantName)
|
||||
}
|
||||
return fmt.Sprintf(`Hello %s,
|
||||
|
||||
Your booking has been rescheduled.
|
||||
|
||||
New details:
|
||||
- Service: %s
|
||||
- Date & Time: %s
|
||||
- Location: %s
|
||||
- Reference: %s
|
||||
|
||||
Manage booking: %s
|
||||
|
||||
%s`, data.CustomerName, data.Service, localizedTime, data.Location, data.Reference, data.ManagementURL, data.TenantName)
|
||||
|
||||
case EmailTypeCancellation:
|
||||
if data.Locale == "cs" {
|
||||
return fmt.Sprintf(`Dobrý den %s,
|
||||
|
||||
vaše rezervace byla zrušena.
|
||||
|
||||
Zrušená rezervace:
|
||||
- Služba: %s
|
||||
- Datum a čas: %s
|
||||
- Reference: %s
|
||||
|
||||
Pokud jste rezervaci nezrušili vy, kontaktujte nás: %s
|
||||
|
||||
%s`, data.CustomerName, data.Service, localizedTime, data.Reference, data.BusinessEmail, data.TenantName)
|
||||
}
|
||||
return fmt.Sprintf(`Hello %s,
|
||||
|
||||
Your booking has been cancelled.
|
||||
|
||||
Cancelled booking:
|
||||
- Service: %s
|
||||
- Date & Time: %s
|
||||
- Reference: %s
|
||||
|
||||
If you didn't cancel this, please contact us: %s
|
||||
|
||||
%s`, data.CustomerName, data.Service, localizedTime, data.Reference, data.BusinessEmail, data.TenantName)
|
||||
|
||||
case EmailTypeBusinessNotify:
|
||||
if data.Locale == "cs" {
|
||||
return fmt.Sprintf(`Nová rezervace od %s
|
||||
|
||||
Detaily:
|
||||
- Služba: %s
|
||||
- Datum a čas: %s
|
||||
- Reference: %s
|
||||
- Email: %s
|
||||
|
||||
Spravovat v administraci: https://bookra.eu/dashboard`, data.CustomerName, data.Service, localizedTime, data.Reference, data.CustomerEmail)
|
||||
}
|
||||
return fmt.Sprintf(`New booking from %s
|
||||
|
||||
Details:
|
||||
- Service: %s
|
||||
- Date & Time: %s
|
||||
- Reference: %s
|
||||
- Email: %s
|
||||
|
||||
Manage in dashboard: https://bookra.eu/dashboard`, data.CustomerName, data.Service, localizedTime, data.Reference, data.CustomerEmail)
|
||||
|
||||
default:
|
||||
return "Booking update"
|
||||
}
|
||||
}
|
||||
|
||||
func renderHTMLBody(data BookingEmailData) string {
|
||||
// For now, return simple HTML version. In production, this would use proper HTML templates
|
||||
textBody := renderTextBody(data)
|
||||
// Simple conversion: wrap paragraphs in <p> tags and preserve line breaks
|
||||
html := "<html><body style='font-family: Arial, sans-serif; line-height: 1.6; color: #333;'>"
|
||||
html += "<div style='max-width: 600px; margin: 0 auto; padding: 20px;'>"
|
||||
html += fmt.Sprintf("<h2 style='color: %s; margin-bottom: 20px;'>%s</h2>", data.BrandColor, data.TenantName)
|
||||
|
||||
// Convert text to simple HTML
|
||||
paragraphs := splitParagraphs(textBody)
|
||||
for _, p := range paragraphs {
|
||||
if len(p) > 0 {
|
||||
html += fmt.Sprintf("<p style='margin-bottom: 10px;'>%s</p>", p)
|
||||
}
|
||||
}
|
||||
|
||||
// Add management button
|
||||
if data.ManagementURL != "" {
|
||||
html += fmt.Sprintf("<div style='margin-top: 30px;'><a href='%s' style='display: inline-block; background: %s; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px;'>Manage Booking</a></div>", data.ManagementURL, data.BrandColor)
|
||||
}
|
||||
|
||||
html += "</div></body></html>"
|
||||
return html
|
||||
}
|
||||
|
||||
func formatLocalizedTime(t time.Time, timezone, locale string) string {
|
||||
loc, err := time.LoadLocation(timezone)
|
||||
if err != nil {
|
||||
loc = time.UTC
|
||||
}
|
||||
localTime := t.In(loc)
|
||||
if locale == "cs" {
|
||||
return localTime.Format("15:04")
|
||||
}
|
||||
return localTime.Format("3:04 PM")
|
||||
}
|
||||
|
||||
func formatLocalizedDateTime(t time.Time, timezone, locale string) string {
|
||||
loc, err := time.LoadLocation(timezone)
|
||||
if err != nil {
|
||||
loc = time.UTC
|
||||
}
|
||||
localTime := t.In(loc)
|
||||
if locale == "cs" {
|
||||
return localTime.Format("02.01.2006 15:04")
|
||||
}
|
||||
return localTime.Format("Jan 02, 2006 3:04 PM")
|
||||
}
|
||||
|
||||
func splitParagraphs(text string) []string {
|
||||
var paragraphs []string
|
||||
current := ""
|
||||
for _, line := range splitLines(text) {
|
||||
trimmed := trimSpace(line)
|
||||
if trimmed == "" {
|
||||
if current != "" {
|
||||
paragraphs = append(paragraphs, current)
|
||||
current = ""
|
||||
}
|
||||
} else {
|
||||
if current != "" {
|
||||
current += " "
|
||||
}
|
||||
current += trimmed
|
||||
}
|
||||
}
|
||||
if current != "" {
|
||||
paragraphs = append(paragraphs, current)
|
||||
}
|
||||
return paragraphs
|
||||
}
|
||||
|
||||
func splitLines(s string) []string {
|
||||
var lines []string
|
||||
start := 0
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == '\n' {
|
||||
lines = append(lines, s[start:i])
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
lines = append(lines, s[start:])
|
||||
return lines
|
||||
}
|
||||
|
||||
func trimSpace(s string) string {
|
||||
start := 0
|
||||
end := len(s)
|
||||
for start < end && (s[start] == ' ' || s[start] == '\t' || s[start] == '\r' || s[start] == '\n') {
|
||||
start++
|
||||
}
|
||||
for end > start && (s[end-1] == ' ' || s[end-1] == '\t' || s[end-1] == '\r' || s[end-1] == '\n') {
|
||||
end--
|
||||
}
|
||||
return s[start:end]
|
||||
}
|
||||
|
||||
// RenderReminderEmail renders the legacy reminder email from a job record
|
||||
func RenderReminderEmail(from string, job db.ReminderJobRecord) EmailMessage {
|
||||
data := BookingEmailData{
|
||||
Type: EmailTypeReminder,
|
||||
TenantName: job.TenantName,
|
||||
TenantSlug: "", // Not available in job record
|
||||
CustomerName: job.CustomerName,
|
||||
CustomerEmail: job.CustomerEmail,
|
||||
Reference: job.Reference,
|
||||
StartsAt: job.StartsAt,
|
||||
Timezone: job.Timezone,
|
||||
Locale: job.Locale,
|
||||
Service: "Service", // Legacy
|
||||
Location: "Location", // Legacy
|
||||
}
|
||||
return RenderEmailMessage(data)
|
||||
}
|
||||
@@ -4,6 +4,9 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"bookra/apps/backend/internal/config"
|
||||
@@ -23,36 +26,30 @@ type EmailMessage struct {
|
||||
To string
|
||||
Subject string
|
||||
Text string
|
||||
}
|
||||
|
||||
type SMSMessage struct {
|
||||
From string
|
||||
To string
|
||||
Text string
|
||||
HTML string
|
||||
}
|
||||
|
||||
type EmailProvider interface {
|
||||
Send(context.Context, EmailMessage) (DeliveryReceipt, error)
|
||||
}
|
||||
|
||||
type SMSProvider interface {
|
||||
Send(context.Context, SMSMessage) (DeliveryReceipt, error)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
cfg config.Config
|
||||
repo db.Repository
|
||||
emailProvider EmailProvider
|
||||
smsProvider SMSProvider
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewService(cfg config.Config, repo db.Repository) *Service {
|
||||
emailProvider := EmailProvider(noopEmailProvider{})
|
||||
if cfg.SMTPHost != "" {
|
||||
emailProvider = smtpEmailProvider{cfg: cfg}
|
||||
}
|
||||
|
||||
return &Service{
|
||||
cfg: cfg,
|
||||
repo: repo,
|
||||
emailProvider: noopEmailProvider{},
|
||||
smsProvider: noopSMSProvider{},
|
||||
emailProvider: emailProvider,
|
||||
now: func() time.Time { return time.Now().UTC() },
|
||||
}
|
||||
}
|
||||
@@ -86,15 +83,6 @@ func (s *Service) DispatchDue(ctx context.Context, limit int) (domain.DispatchRe
|
||||
provider = receipt.Provider
|
||||
externalID = receipt.ExternalID
|
||||
}
|
||||
case "sms":
|
||||
receipt, sendErr := s.smsProvider.Send(ctx, renderSMSMessage(s.cfg.SMSFrom, job))
|
||||
if sendErr != nil {
|
||||
status = "failed"
|
||||
errorMessage = sendErr.Error()
|
||||
} else {
|
||||
provider = receipt.Provider
|
||||
externalID = receipt.ExternalID
|
||||
}
|
||||
default:
|
||||
status = "failed"
|
||||
errorMessage = ErrUnsupportedChannel.Error()
|
||||
@@ -103,8 +91,6 @@ func (s *Service) DispatchDue(ctx context.Context, limit int) (domain.DispatchRe
|
||||
if provider == "unknown" {
|
||||
if job.Channel == "email" {
|
||||
provider = "noop-email"
|
||||
} else if job.Channel == "sms" {
|
||||
provider = "noop-sms"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,23 +120,53 @@ func (s *Service) DispatchDue(ctx context.Context, limit int) (domain.DispatchRe
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func renderEmailMessage(from string, job db.ReminderJobRecord) EmailMessage {
|
||||
subject, body := renderReminderCopy(job)
|
||||
return EmailMessage{
|
||||
From: from,
|
||||
To: job.CustomerEmail,
|
||||
Subject: subject,
|
||||
Text: body,
|
||||
// SendBookingConfirmation sends a booking confirmation email to the customer
|
||||
func (s *Service) SendBookingConfirmation(ctx context.Context, data BookingEmailData) error {
|
||||
if data.BusinessEmail == "" {
|
||||
data.BusinessEmail = s.cfg.EmailFrom
|
||||
}
|
||||
data.Type = EmailTypeConfirmation
|
||||
msg := RenderEmailMessage(data)
|
||||
_, err := s.emailProvider.Send(ctx, msg)
|
||||
return err
|
||||
}
|
||||
|
||||
func renderSMSMessage(from string, job db.ReminderJobRecord) SMSMessage {
|
||||
subject, body := renderReminderCopy(job)
|
||||
return SMSMessage{
|
||||
From: from,
|
||||
To: job.CustomerEmail,
|
||||
Text: fmt.Sprintf("%s: %s", subject, body),
|
||||
// SendBookingReschedule sends a reschedule notification email to the customer
|
||||
func (s *Service) SendBookingReschedule(ctx context.Context, data BookingEmailData) error {
|
||||
if data.BusinessEmail == "" {
|
||||
data.BusinessEmail = s.cfg.EmailFrom
|
||||
}
|
||||
data.Type = EmailTypeReschedule
|
||||
msg := RenderEmailMessage(data)
|
||||
_, err := s.emailProvider.Send(ctx, msg)
|
||||
return err
|
||||
}
|
||||
|
||||
// SendBookingCancellation sends a cancellation notification email to the customer
|
||||
func (s *Service) SendBookingCancellation(ctx context.Context, data BookingEmailData) error {
|
||||
if data.BusinessEmail == "" {
|
||||
data.BusinessEmail = s.cfg.EmailFrom
|
||||
}
|
||||
data.Type = EmailTypeCancellation
|
||||
msg := RenderEmailMessage(data)
|
||||
_, err := s.emailProvider.Send(ctx, msg)
|
||||
return err
|
||||
}
|
||||
|
||||
// SendBusinessNotification sends a notification to the business about a new booking
|
||||
func (s *Service) SendBusinessNotification(ctx context.Context, businessEmail string, data BookingEmailData) error {
|
||||
if businessEmail == "" {
|
||||
return nil // Skip if no business email configured
|
||||
}
|
||||
data.Type = EmailTypeBusinessNotify
|
||||
msg := RenderEmailMessage(data)
|
||||
msg.To = businessEmail
|
||||
_, err := s.emailProvider.Send(ctx, msg)
|
||||
return err
|
||||
}
|
||||
|
||||
func renderEmailMessage(from string, job db.ReminderJobRecord) EmailMessage {
|
||||
return RenderReminderEmail(from, job)
|
||||
}
|
||||
|
||||
func renderReminderCopy(job db.ReminderJobRecord) (string, string) {
|
||||
@@ -190,9 +206,6 @@ func localizedStartsAt(job db.ReminderJobRecord) string {
|
||||
}
|
||||
|
||||
func reminderRecipient(job db.ReminderJobRecord) string {
|
||||
if job.Channel == "email" {
|
||||
return job.CustomerEmail
|
||||
}
|
||||
return job.CustomerEmail
|
||||
}
|
||||
|
||||
@@ -208,14 +221,67 @@ func (noopEmailProvider) Send(_ context.Context, message EmailMessage) (Delivery
|
||||
}, nil
|
||||
}
|
||||
|
||||
type noopSMSProvider struct{}
|
||||
type smtpEmailProvider struct {
|
||||
cfg config.Config
|
||||
}
|
||||
|
||||
func (noopSMSProvider) Send(_ context.Context, message SMSMessage) (DeliveryReceipt, error) {
|
||||
if message.To == "" {
|
||||
return DeliveryReceipt{Provider: "noop-sms"}, errors.New("missing sms recipient")
|
||||
func (p smtpEmailProvider) Send(_ context.Context, message EmailMessage) (DeliveryReceipt, error) {
|
||||
if strings.TrimSpace(message.To) == "" {
|
||||
return DeliveryReceipt{Provider: "smtp"}, errors.New("missing email recipient")
|
||||
}
|
||||
|
||||
host := strings.TrimSpace(p.cfg.SMTPHost)
|
||||
if host == "" {
|
||||
return DeliveryReceipt{Provider: "smtp"}, errors.New("smtp host is not configured")
|
||||
}
|
||||
|
||||
address := net.JoinHostPort(host, strings.TrimSpace(p.cfg.SMTPPort))
|
||||
var auth smtp.Auth
|
||||
if p.cfg.SMTPUsername != "" {
|
||||
auth = smtp.PlainAuth("", p.cfg.SMTPUsername, p.cfg.SMTPPassword, host)
|
||||
}
|
||||
|
||||
// Build multipart email with both plain text and HTML
|
||||
var payload string
|
||||
if message.HTML != "" {
|
||||
boundary := "BOOKRA-BOUNDARY"
|
||||
payload = strings.Join([]string{
|
||||
fmt.Sprintf("From: %s", message.From),
|
||||
fmt.Sprintf("To: %s", message.To),
|
||||
fmt.Sprintf("Subject: %s", message.Subject),
|
||||
"MIME-Version: 1.0",
|
||||
fmt.Sprintf("Content-Type: multipart/alternative; boundary=\"%s\"", boundary),
|
||||
"",
|
||||
fmt.Sprintf("--%s", boundary),
|
||||
"Content-Type: text/plain; charset=UTF-8",
|
||||
"",
|
||||
message.Text,
|
||||
"",
|
||||
fmt.Sprintf("--%s", boundary),
|
||||
"Content-Type: text/html; charset=UTF-8",
|
||||
"",
|
||||
message.HTML,
|
||||
"",
|
||||
fmt.Sprintf("--%s--", boundary),
|
||||
}, "\r\n")
|
||||
} else {
|
||||
payload = strings.Join([]string{
|
||||
fmt.Sprintf("From: %s", message.From),
|
||||
fmt.Sprintf("To: %s", message.To),
|
||||
fmt.Sprintf("Subject: %s", message.Subject),
|
||||
"MIME-Version: 1.0",
|
||||
"Content-Type: text/plain; charset=UTF-8",
|
||||
"",
|
||||
message.Text,
|
||||
}, "\r\n")
|
||||
}
|
||||
|
||||
if err := smtp.SendMail(address, auth, message.From, []string{message.To}, []byte(payload)); err != nil {
|
||||
return DeliveryReceipt{Provider: "smtp"}, err
|
||||
}
|
||||
|
||||
return DeliveryReceipt{
|
||||
Provider: "noop-sms",
|
||||
ExternalID: fmt.Sprintf("noop-sms-%d", time.Now().UnixNano()),
|
||||
Provider: "smtp",
|
||||
ExternalID: fmt.Sprintf("smtp-%d", time.Now().UnixNano()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -38,7 +38,6 @@ func TestDispatchDueProcessesPendingEmailReminders(t *testing.T) {
|
||||
service := NewService(config.Config{
|
||||
Environment: "development",
|
||||
EmailFrom: "noreply@bookra.dev",
|
||||
SMSFrom: "Bookra",
|
||||
}, repo)
|
||||
|
||||
response, err := service.DispatchDue(context.Background(), 10)
|
||||
|
||||
Reference in New Issue
Block a user