diff --git a/server/src/go.mod b/server/src/go.mod index f4040041..9c50c9ca 100644 --- a/server/src/go.mod +++ b/server/src/go.mod @@ -16,6 +16,7 @@ require ( github.com/stretchr/testify v1.4.0 github.com/urfave/negroni v1.0.0 golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 + golang.org/x/text v0.3.2 google.golang.org/api v0.14.0 ) @@ -32,7 +33,6 @@ require ( golang.org/x/crypto v0.0.0-20191119213627-4f8c1d86b1ba // indirect golang.org/x/net v0.0.0-20191119073136-fc4aabc6c914 // indirect golang.org/x/sys v0.0.0-20191119195528-f068ffe820e4 // indirect - golang.org/x/text v0.3.2 // indirect google.golang.org/appengine v1.6.5 // indirect google.golang.org/genproto v0.0.0-20191115221424-83cc0476cb11 // indirect google.golang.org/grpc v1.25.1 // indirect diff --git a/server/src/international_application_processor/README.md b/server/src/international_application_processor/README.md new file mode 100644 index 00000000..9386c371 --- /dev/null +++ b/server/src/international_application_processor/README.md @@ -0,0 +1,15 @@ +# International Application Processor + +This package reads form submissions from https://dxe.io/join and sends emails +to the person who responded to the form inviting them to get involved, called +the "**onboarding**" email, as well as to existing organizers or coordinators, +called the "**notification**" email. + +Please keep the logic in this package in sync with [this Google Doc][doc] for +email authors to see the emails that are sent and to suggest changes. + +Further related documentation for this mechanism can be found in [Coda][coda]. + +[doc]: https://docs.google.com/document/d/1tgtxGONu86XN0KBvOx9-OXmh3mDKpvzkAyQGFoHOdkM/edit?tab=t.0#heading=h.qsu6qdtc3163 + +[coda]: https://coda.io/d/Tech-Team_dR-UIgVShEf/ADB-Forms_suuKCpXS diff --git a/server/src/international_application_processor/mailer_common.go b/server/src/international_application_processor/mailer_common.go new file mode 100644 index 00000000..491e1342 --- /dev/null +++ b/server/src/international_application_processor/mailer_common.go @@ -0,0 +1,99 @@ +package international_application_processor + +import ( + "net/mail" + "strings" + "unicode" + + "github.com/dxe/adb/model" + "golang.org/x/text/cases" + "golang.org/x/text/language" +) + +var ( + sfBayCoordinator = mail.Address{ + Name: "Antonelle Racelis", + Address: "antonelle@directactioneverywhere.com", + } + californiaCoordinator = mail.Address{ + Name: "Almira Tanner", + Address: "almira@directactioneverywhere.com", + } + globalCoordinator = mail.Address{ + Name: "Michelle Del Cueto", + Address: "internationalcoordination@directactioneverywhere.com", + } +) + +func stateIsCalifornia(state string) bool { + return state == "CA" +} + +func getChapterEmailFallback(state string) string { + if stateIsCalifornia(state) { + return californiaCoordinator.Address + } else { + return globalCoordinator.Address + } +} + +func getChapterEmailsWithFallback(chapter *model.ChapterWithToken, fallback string) []string { + emails := getChapterEmails(chapter) + if len(emails) == 0 { + return []string{fallback} + } + return emails +} + +func getChapterEmails(chapter *model.ChapterWithToken) []string { + var emails []string + + if chapter.Email != "" { + emails = append(emails, chapter.Email) + } + + emails = append(emails, getChapterOrganizerEmails(chapter)...) + + return emails +} + +func getChapterOrganizerEmails(chapter *model.ChapterWithToken) []string { + organizers := chapter.Organizers + + emails := make([]string, 0, len(organizers)) + if len(organizers) > 0 { + for _, o := range organizers { + if o.Email != "" { + emails = append(emails, o.Email) + } + } + } + + return emails +} + +func sanitizeAndFormatName(name string) string { + sanitized := strings.Map(func(r rune) rune { + if unicode.IsLetter(r) || unicode.IsDigit(r) || r == ' ' { + return r + } + return -1 + }, name) + + return strings.TrimSpace( + cases.Title(language.AmericanEnglish).String(sanitized)) +} + +func validateEmail(str string) error { + _, err := mail.ParseAddress(str) + return err +} + +func sanitizeState(state string) string { + return strings.Map(func(r rune) rune { + if unicode.IsLetter(r) { + return r + } + return -1 + }, state) +} diff --git a/server/src/international_application_processor/mailer_common_test.go b/server/src/international_application_processor/mailer_common_test.go new file mode 100644 index 00000000..f9c9333a --- /dev/null +++ b/server/src/international_application_processor/mailer_common_test.go @@ -0,0 +1,68 @@ +package international_application_processor + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSanitizeAndFormatName(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"John Doe", "John Doe"}, + {"Jóhn Doe", "Jóhn Doe"}, + {" John Doe ", "John Doe"}, + {"john \t", "John Doe"}, + {"!@#John123", "John123"}, + {"jane doe the 3rd", "Jane Doe The 3Rd"}, + {"", ""}, + } + + for _, test := range tests { + result := sanitizeAndFormatName(test.input) + assert.Equal(t, test.expected, result, "Expected sanitized and formatted name to match") + } +} + +func TestValidateEmail(t *testing.T) { + tests := []struct { + input string + hasError bool + }{ + {"test@example.com", false}, + {"user@domain.co.uk", false}, + {"user@مثال.com", false}, + {"user@tld", false}, + {"user@", true}, + {"invalid-email", true}, + {"", true}, + } + + for _, test := range tests { + err := validateEmail(test.input) + if test.hasError { + assert.Error(t, err, "Expected an error for invalid email") + } else { + assert.NoError(t, err, "Expected no error for valid email") + } + } +} + +func TestSanitizeState(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"CA", "CA"}, + {"California123", "California"}, + {"!@#CA", "CA"}, + {"", ""}, + } + + for _, test := range tests { + result := sanitizeState(test.input) + assert.Equal(t, test.expected, result, "Expected sanitized state to match") + } +} diff --git a/server/src/international_application_processor/mailer_integration_test.go b/server/src/international_application_processor/mailer_integration_test.go new file mode 100644 index 00000000..2e8a6f3e --- /dev/null +++ b/server/src/international_application_processor/mailer_integration_test.go @@ -0,0 +1,171 @@ +package international_application_processor + +import ( + "flag" + "log" + "testing" + "time" + + "github.com/dxe/adb/mailer" + "github.com/dxe/adb/model" + "github.com/dxe/adb/testfixtures" + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" +) + +// This is a test of the integration of this package with the email service +// and allows viewing the emails in a real email client. + +// SMTP_ environment variables must be configured to run this test. + +var enableMailerIntegrationTest = flag.Bool("intl-application-mailer-integration-test-enable", false, "Enable the international application form processor mailer integration test") +var mailerIntegrationTestEmail = flag.String("intl-application-mailer-integration-test-email", "", "Email for the international application form processor mailer integration test") + +type coords struct { + lat float64 + lng float64 +} + +var downtownBerkeley = coords{ + lat: 37.870352730245024, + lng: -122.26794876651053, +} + +func makeFormData(location coords, state string, interest string) model.InternationalFormData { + return testfixtures.NewInternationalFormDataBuilder(). + WithFirstName("John"). + WithLastName("Doe"). + WithEmail(*mailerIntegrationTestEmail). + WithLat(location.lat). + WithLng(location.lng). + WithState(state). + WithInterest(interest). + Build() +} + +func makeSfBayChapter() model.ChapterWithToken { + return testfixtures.NewChapterBuilder(). + WithChapterID(model.SFBayChapterId). + WithName("SF Bay"). + WithFbURL("https://facebook.com/test-chapter"). + WithInstaURL("https://instagram.com/test-chapter"). + WithTwitterURL("https://twitter.com/test-chapter"). + WithEmail("chapter-email@example.org"). + Build() +} + +func makeEvent() *model.ExternalEvent { + return &model.ExternalEvent{ + ID: "555500", + Name: "Test Event", + StartTime: time.Now().Add(48 * time.Hour), + } +} + +func replaceRecipientsWithTestAddress(msg *mailer.Message) { + // Replace the recipient with the test email address + msg.ToAddress = *mailerIntegrationTestEmail + msg.CC = []string{} + msg.BCC = []string{} +} + +func sendTestNotificationEmail(formData model.InternationalFormData, chapter *model.ChapterWithToken) error { + msg, err := buildNotificationEmail(formData, chapter) + if err != nil { + return errors.Wrap(err, "error building int'l application notification email") + } + + replaceRecipientsWithTestAddress(msg) + + err = mailer.Send(*msg) + if err != nil { + return errors.Wrap(err, "error sending int'l application notification email") + } + + log.Printf("Test int'l application notification email sent to %v", formData.Email) + + return err +} + +func sendTestOnboardingEmail(formData model.InternationalFormData, chapter *model.ChapterWithToken) error { + msg, err := buildOnboardingEmailMessage(formData, chapter, makeEvent()) + if err != nil { + return errors.Wrap(err, "error building email message") + } + + replaceRecipientsWithTestAddress(msg) + + if err := mailer.Send(*msg); err != nil { + return errors.Wrap(err, "error sending email for international form submission") + } + + log.Printf("Int'l mailer onboarding email sent to %v", msg.ToAddress) + + return nil +} + +func TestMailIntegration(t *testing.T) { + flag.Parse() + if !*enableMailerIntegrationTest { + t.Skip() + return + } + if *mailerIntegrationTestEmail == "" { + t.Skip("No email address provided for mailer integration test") + return + } + + t.Run("SendsNotificationEmail", func(t *testing.T) { + formData := makeFormData(downtownBerkeley, "CA", "participate") + chapter := makeSfBayChapter() + + err := sendTestNotificationEmail(formData, &chapter) + assert.NoError(t, err) + }) + + t.Run("OnboardingEmails", func(t *testing.T) { + t.Run("SendsSFBayEmail", func(t *testing.T) { + formData := makeFormData(downtownBerkeley, "CA", "participate") + chapter := makeSfBayChapter() + + err := sendTestOnboardingEmail(formData, &chapter) + assert.NoError(t, err) + }) + + t.Run("SendsNearbyChapterEmail", func(t *testing.T) { + formData := makeFormData(coords{0, 0}, "ZZ", "participate") + chapter := testfixtures.NewChapterBuilder().Build() + + err := sendTestOnboardingEmail(formData, &chapter) + assert.NoError(t, err) + }) + + t.Run("SendsCAOrganizerEmail", func(t *testing.T) { + formData := makeFormData(coords{0, 0}, "CA", "organize") + + err := sendTestOnboardingEmail(formData, nil) + assert.NoError(t, err) + }) + + t.Run("SendsNonCAOrganizerEmail", func(t *testing.T) { + formData := makeFormData(coords{0, 0}, "ZZ", "organize") + + err := sendTestOnboardingEmail(formData, nil) + assert.NoError(t, err) + }) + + t.Run("SendsCAParticipatantEmail", func(t *testing.T) { + formData := makeFormData(coords{0, 0}, "CA", "participate") + + err := sendTestOnboardingEmail(formData, nil) + assert.NoError(t, err) + }) + + t.Run("SendsNonCAParticipantEmail", func(t *testing.T) { + formData := makeFormData(coords{0, 0}, "ZZ", "participate") + + err := sendTestOnboardingEmail(formData, nil) + assert.NoError(t, err) + }) + }) +} diff --git a/server/src/international_application_processor/notification_mailer.go b/server/src/international_application_processor/notification_mailer.go new file mode 100644 index 00000000..b5335a9f --- /dev/null +++ b/server/src/international_application_processor/notification_mailer.go @@ -0,0 +1,72 @@ +// Logic for notifying existing organizers that someone submitted the +// international application form. + +package international_application_processor + +import ( + "fmt" + "html" + "log" + "strings" + + "github.com/dxe/adb/mailer" + "github.com/dxe/adb/model" + "github.com/pkg/errors" +) + +func sendNotificationEmail(formData model.InternationalFormData, chapter *model.ChapterWithToken) error { + msg, err := buildNotificationEmail(formData, chapter) + if err != nil { + return errors.Wrap(err, "error building int'l application notification email") + } + + err = mailer.Send(*msg) + if err != nil { + return errors.Wrap(err, "error sending int'l application notification email") + } + + log.Printf("Int'l application notification email sent to %v", formData.Email) + + return err +} + +func buildNotificationEmail(formData model.InternationalFormData, chapter *model.ChapterWithToken) (*mailer.Message, error) { + recipients, err := getNotificationRecipients(chapter, formData.State) + if err != nil { + return nil, err + } + + fullName := sanitizeAndFormatName(formData.FirstName + " " + formData.LastName) + + msg := mailer.Message{ + FromName: "DxE Join Form", + FromAddress: "noreply@directactioneverywhere.com", + ToAddress: recipients[0], + CC: recipients[1:], + Subject: fmt.Sprintf("%v signed up to join your chapter", fullName), + } + + var bodyBuilder strings.Builder + fmt.Fprintf(&bodyBuilder, "

Name: %s

", html.EscapeString(fullName)) + fmt.Fprintf(&bodyBuilder, "

Email: %s

", html.EscapeString(formData.Email)) + fmt.Fprintf(&bodyBuilder, "

Phone: %s

", html.EscapeString(formData.Phone)) + fmt.Fprintf(&bodyBuilder, "

City: %s

", html.EscapeString(formData.City)) + fmt.Fprintf(&bodyBuilder, "

Involvement: %s

", html.EscapeString(formData.Involvement)) + fmt.Fprintf(&bodyBuilder, "

Skills: %s

", html.EscapeString(formData.Skills)) + msg.BodyHTML = bodyBuilder.String() + + return &msg, nil +} + +func getNotificationRecipients(chapter *model.ChapterWithToken, state string) ([]string, error) { + if chapter == nil { + return []string{getChapterEmailFallback(state)}, nil + } + + if chapter.ChapterID == model.SFBayChapterId { + return []string{sfBayCoordinator.Address}, nil + } + + return getChapterEmailsWithFallback(chapter, + getChapterEmailFallback(state)), nil +} diff --git a/server/src/international_application_processor/notification_mailer_test.go b/server/src/international_application_processor/notification_mailer_test.go new file mode 100644 index 00000000..ae27d365 --- /dev/null +++ b/server/src/international_application_processor/notification_mailer_test.go @@ -0,0 +1,141 @@ +package international_application_processor + +import ( + "testing" + + "github.com/dxe/adb/model" + "github.com/dxe/adb/testfixtures" + "github.com/stretchr/testify/require" +) + +func TestBuildNotificationEmail(t *testing.T) { + t.Run("IncludesResponderInfo", func(t *testing.T) { + // Arrange + formData := testfixtures.NewInternationalFormDataBuilder(). + WithEmail("test@example.com"). + WithFirstName("John"). + WithLastName("Doe"). + WithPhone("123-456-7890"). + WithCity("Test City"). + Build() + + chapter := testfixtures.NewChapterBuilder().Build() + + // Act + msg, err := buildNotificationEmail(formData, &chapter) + + // Assert + require.NoError(t, err) + require.NotNil(t, msg) + require.Equal(t, "John Doe signed up to join your chapter", msg.Subject) + require.Contains(t, msg.BodyHTML, "Name: John Doe") + require.Contains(t, msg.BodyHTML, "Email: test@example.com") + require.Contains(t, msg.BodyHTML, "Phone: 123-456-7890") + require.Contains(t, msg.BodyHTML, "City: Test City") + }) + + t.Run("EmailsChapterEmail", func(t *testing.T) { + // Arrange + formData := testfixtures.NewInternationalFormDataBuilder().Build() + + chapter := testfixtures.NewChapterBuilder(). + WithEmail("chapter@example.com"). + Build() + + // Act + msg, err := buildNotificationEmail(formData, &chapter) + + // Assert + require.NoError(t, err) + require.NotNil(t, msg) + require.Equal(t, "chapter@example.com", msg.ToAddress) + }) + + t.Run("EmailsChapterOrganizers", func(t *testing.T) { + // Arrange + formData := testfixtures.NewInternationalFormDataBuilder().Build() + + chapter := testfixtures.NewChapterBuilder(). + WithEmail(""). + WithOrganizers([]*model.Organizer{ + {Name: "Organizer 1", Email: "organizer1@example.com"}, + {Name: "Organizer 2", Email: "organizer2@example.com"}, + }). + Build() + + // Act + msg, err := buildNotificationEmail(formData, &chapter) + + // Assert + require.NoError(t, err) + require.NotNil(t, msg) + require.Equal(t, "organizer1@example.com", msg.ToAddress) + require.Equal(t, 1, len(msg.CC)) + require.Equal(t, "organizer2@example.com", msg.CC[0]) + }) + + t.Run("EmailsChapterAndOrganizers", func(t *testing.T) { + // Arrange + formData := testfixtures.NewInternationalFormDataBuilder().Build() + + chapter := testfixtures.NewChapterBuilder(). + WithEmail("chapter@example.com"). + WithOrganizers([]*model.Organizer{ + {Name: "Organizer 1", Email: "organizer1@example.com"}, + {Name: "Organizer 2", Email: "organizer2@example.com"}, + }). + Build() + + // Act + msg, err := buildNotificationEmail(formData, &chapter) + + // Assert + require.NoError(t, err) + require.NotNil(t, msg) + require.Equal(t, "chapter@example.com", msg.ToAddress) + require.Equal(t, 2, len(msg.CC)) + require.Equal(t, "organizer1@example.com", msg.CC[0]) + require.Equal(t, "organizer2@example.com", msg.CC[1]) + }) + + t.Run("EmailsSFBayCoordinator", func(t *testing.T) { + // Arrange + formData := testfixtures.NewInternationalFormDataBuilder().Build() + + chapter := testfixtures.NewChapterBuilder(). + WithChapterID(model.SFBayChapterId). + Build() + + // Act + msg, err := buildNotificationEmail(formData, &chapter) + + // Assert + require.NoError(t, err) + require.NotNil(t, msg) + require.Equal(t, sfBayCoordinator.Address, msg.ToAddress) + }) + + t.Run("EmailsCaliforniaCoordinator", func(t *testing.T) { + formData := testfixtures.NewInternationalFormDataBuilder(). + WithState("CA"). + Build() + + msg, err := buildNotificationEmail(formData, nil) + + require.NoError(t, err) + require.NotNil(t, msg) + require.Equal(t, californiaCoordinator.Address, msg.ToAddress) + }) + + t.Run("EmailsGlobalCoordinator", func(t *testing.T) { + formData := testfixtures.NewInternationalFormDataBuilder(). + WithState("ZZ"). + Build() + + msg, err := buildNotificationEmail(formData, nil) + + require.NoError(t, err) + require.NotNil(t, msg) + require.Equal(t, globalCoordinator.Address, msg.ToAddress) + }) +} diff --git a/server/src/international_application_processor/onboarding_mailer.go b/server/src/international_application_processor/onboarding_mailer.go new file mode 100644 index 00000000..3b2fa8d4 --- /dev/null +++ b/server/src/international_application_processor/onboarding_mailer.go @@ -0,0 +1,58 @@ +// Logic for emailing the applicant / responder after they submit the +// international application form. + +package international_application_processor + +import ( + "log" + "time" + + "github.com/dxe/adb/mailer" + "github.com/dxe/adb/model" + "github.com/jmoiron/sqlx" + "github.com/pkg/errors" +) + +func sendOnboardingEmail(db *sqlx.DB, formData model.InternationalFormData, chapter *model.ChapterWithToken) error { + nextEvent, err := getNextEventOrNil(db, chapter) + if err != nil { + log.Println("error getting next event:", err) + } + + msg, err := buildOnboardingEmailMessage(formData, chapter, nextEvent) + if err != nil { + return errors.Wrap(err, "error building email message") + } + + if err := mailer.Send(*msg); err != nil { + return errors.Wrap(err, "error sending email for international form submission") + } + + log.Printf("Int'l mailer onboarding email sent to %v", msg.ToAddress) + + return nil +} + +// getNextEventOrNil retrieves the next event for the given chapter within a +// time range. If no events are found, or if `chapter` is nil, it returns nil. +func getNextEventOrNil(db *sqlx.DB, chapter *model.ChapterWithToken) (*model.ExternalEvent, error) { + if chapter == nil { + return nil, nil + } + + if chapter.ID == 0 { + return nil, errors.New("chapter ID cannot be 0") + } + + startTime := time.Now() + endTime := time.Now().Add(60 * 24 * time.Hour) + events, err := model.GetExternalEvents(db, chapter.ID, startTime, endTime) + if err != nil { + return nil, err + } + if len(events) == 0 { + return nil, nil + } + + return &events[0], nil +} diff --git a/server/src/international_application_processor/onboarding_mailer_test.go b/server/src/international_application_processor/onboarding_mailer_test.go new file mode 100644 index 00000000..e9d75543 --- /dev/null +++ b/server/src/international_application_processor/onboarding_mailer_test.go @@ -0,0 +1,304 @@ +package international_application_processor + +import ( + "testing" + + "time" + + "github.com/dxe/adb/model" + "github.com/dxe/adb/testfixtures" + "github.com/stretchr/testify/assert" +) + +func TestBuildOnboardingEmailMessage(t *testing.T) { + t.Run("ForSFBayChapter", func(t *testing.T) { + t.Run("ContainsBasicInfo", func(t *testing.T) { + // Arrange + formData := testfixtures.NewInternationalFormDataBuilder(). + WithFirstName("John"). + WithLastName("Doe"). + WithEmail("john.doe@example.com"). + Build() + + chapter := testfixtures.NewChapterBuilder(). + WithChapterID(model.SFBayChapterId). + WithName("SF Bay"). + WithFbURL("https://facebook.com/test-chapter"). + WithInstaURL("https://instagram.com/test-chapter"). + WithTwitterURL("https://twitter.com/test-chapter"). + WithEmail("chapter-email@example.org"). + Build() + + // Act + msg, err := buildOnboardingEmailMessage(formData, &chapter, nil) + + // Assert + assert.NoError(t, err) + assert.NotNil(t, msg) + assert.Equal(t, sfBayCoordinator.Name, msg.FromName) + assert.Equal(t, sfBayCoordinator.Address, msg.FromAddress) + assert.Contains(t, msg.BCC, sfBayCoordinator.Address) + assert.Equal(t, "John Doe", msg.ToName) + assert.Equal(t, "john.doe@example.com", msg.ToAddress) + assert.Equal(t, "Join your local Direct Action Everywhere chapter!", msg.Subject) + assert.Contains(t, msg.BodyHTML, "Hey John!") + assert.Contains(t, msg.BodyHTML, "The DxE SF Bay Area chapter is within 100 miles of you.") + assert.Contains(t, msg.BodyHTML, "https://dxe.io/events") + }) + + t.Run("ContainsNextEventLink", func(t *testing.T) { + // Arrange + formData := testfixtures.NewInternationalFormDataBuilder().Build() + chapter := testfixtures.NewChapterBuilder().Build() + nextEvent := &model.ExternalEvent{ + ID: "555500", + Name: "Test Event", + StartTime: time.Now().Add(48 * time.Hour), + } + + // Act + msg, err := buildOnboardingEmailMessage(formData, &chapter, nextEvent) + + // Assert + assert.NoError(t, err) + assert.NotNil(t, msg) + assert.Contains(t, msg.BodyHTML, "Test Event") + assert.Contains(t, msg.BodyHTML, "https://facebook.com/events/555500") + }) + }) + + t.Run("ForNonSFBayChapter", func(t *testing.T) { + t.Run("ContainsBasicInfo", func(t *testing.T) { + // Arrange + formData := testfixtures.NewInternationalFormDataBuilder(). + WithFirstName("John"). + WithLastName("Doe"). + WithEmail("john.doe@example.com"). + Build() + + chapter := testfixtures.NewChapterBuilder(). + WithName("Test Chapter"). + WithFbURL("https://facebook.com/test-chapter"). + WithInstaURL("https://instagram.com/test-chapter"). + WithTwitterURL("https://twitter.com/test-chapter"). + WithEmail("chapter-email@example.org"). + Build() + + // Act + msg, err := buildOnboardingEmailMessage(formData, &chapter, nil) + + // Assert + assert.NoError(t, err) + assert.NotNil(t, msg) + assert.Equal(t, globalCoordinator.Name, msg.FromName) + assert.Equal(t, globalCoordinator.Address, msg.FromAddress) + assert.Contains(t, msg.BCC, globalCoordinator.Address) + assert.Equal(t, "John Doe", msg.ToName) + assert.Equal(t, "john.doe@example.com", msg.ToAddress) + assert.Equal(t, "Join your local Direct Action Everywhere chapter!", msg.Subject) + assert.Contains(t, msg.BodyHTML, "Hey John!") + assert.Contains(t, msg.BodyHTML, "There is currently a DxE chapter near you") + assert.Contains(t, msg.BodyHTML, "Test Chapter") + assert.Contains(t, msg.BodyHTML, "https://facebook.com/test-chapter") + assert.Contains(t, msg.BodyHTML, "https://instagram.com/test-chapter") + assert.Contains(t, msg.BodyHTML, "https://twitter.com/test-chapter") + assert.Contains(t, msg.BodyHTML, "mailto:chapter-email@example.org") + }) + + t.Run("ContainsNextEventLink", func(t *testing.T) { + // Arrange + formData := testfixtures.NewInternationalFormDataBuilder().Build() + chapter := testfixtures.NewChapterBuilder().Build() + nextEvent := &model.ExternalEvent{ + ID: "555500", + Name: "Test Event", + StartTime: time.Now().Add(48 * time.Hour), + } + + // Act + msg, err := buildOnboardingEmailMessage(formData, &chapter, nextEvent) + + // Assert + assert.NoError(t, err) + assert.NotNil(t, msg) + assert.Contains(t, msg.BodyHTML, "Test Event") + assert.Contains(t, msg.BodyHTML, "https://facebook.com/events/555500") + }) + + t.Run("CCsChapterEmailAndOrganizers", func(t *testing.T) { + // Arrange + formData := testfixtures.NewInternationalFormDataBuilder().Build() + + chapter := testfixtures.NewChapterBuilder(). + WithEmail("test-email@example.org"). + WithOrganizers([]*model.Organizer{ + {Name: "Test Organizer", Email: "organizer1@example.org"}, + {Name: "Test Organizer 2", Email: "organizer2@example.org"}, + }). + Build() + + // Act + msg, err := buildOnboardingEmailMessage(formData, &chapter, nil) + + // Assert + assert.NoError(t, err) + assert.NotNil(t, msg) + assert.Contains(t, msg.CC, "test-email@example.org") + assert.Contains(t, msg.CC, "organizer1@example.org") + assert.Contains(t, msg.CC, "organizer2@example.org") + assert.Equal(t, 3, len(msg.CC)) + }) + + t.Run("CCsGlobalCoordinator", func(t *testing.T) { + // Arrange + formData := testfixtures.NewInternationalFormDataBuilder(). + WithState("ZZ"). + Build() + + chapter := testfixtures.NewChapterBuilder(). + WithEmail(""). + WithOrganizers([]*model.Organizer{}). + Build() + + // Act + msg, err := buildOnboardingEmailMessage(formData, &chapter, nil) + + // Assert + assert.NoError(t, err) + assert.NotNil(t, msg) + assert.Contains(t, msg.CC, globalCoordinator.Address) + }) + + t.Run("CCsCACoordinator", func(t *testing.T) { + // Arrange + formData := testfixtures.NewInternationalFormDataBuilder(). + WithState("CA"). + Build() + + chapter := testfixtures.NewChapterBuilder(). + WithEmail(""). + WithOrganizers([]*model.Organizer{}). + Build() + + // Act + msg, err := buildOnboardingEmailMessage(formData, &chapter, nil) + + // Assert + assert.NoError(t, err) + assert.NotNil(t, msg) + assert.Contains(t, msg.CC, californiaCoordinator.Address) + }) + }) + + t.Run("ForCaOrganizerNotNearAnyChapter", func(t *testing.T) { + t.Run("ContainsBasicInfo", func(t *testing.T) { + // Arrange + formData := testfixtures.NewInternationalFormDataBuilder(). + WithFirstName("John"). + WithLastName("Doe"). + WithEmail("john.doe@example.com"). + WithInvolvement("organize"). + WithState("CA"). + Build() + + // Act + msg, err := buildOnboardingEmailMessage(formData, nil, nil) + + // Assert + assert.NoError(t, err) + assert.NotNil(t, msg) + assert.Equal(t, californiaCoordinator.Name, msg.FromName) + assert.Equal(t, californiaCoordinator.Address, msg.FromAddress) + assert.Contains(t, msg.BCC, californiaCoordinator.Address) + assert.Equal(t, "John Doe", msg.ToName) + assert.Equal(t, "john.doe@example.com", msg.ToAddress) + assert.Equal(t, "Getting involved with Direct Action Everywhere", msg.Subject) + assert.Contains(t, msg.BodyHTML, "Hi John,") + assert.Contains(t, msg.BodyHTML, "There is no active chapter in your area") + }) + }) + + t.Run("ForNonCaOrganizerNotNearAnyChapter", func(t *testing.T) { + t.Run("ContainsBasicInfo", func(t *testing.T) { + // Arrange + formData := testfixtures.NewInternationalFormDataBuilder(). + WithFirstName("John"). + WithLastName("Doe"). + WithEmail("john.doe@example.com"). + WithInvolvement("organize"). + WithState("ZZ"). + Build() + + // Act + msg, err := buildOnboardingEmailMessage(formData, nil, nil) + + // Assert + assert.NoError(t, err) + assert.NotNil(t, msg) + assert.Equal(t, globalCoordinator.Name, msg.FromName) + assert.Equal(t, globalCoordinator.Address, msg.FromAddress) + assert.Contains(t, msg.BCC, globalCoordinator.Address) + assert.Equal(t, "John Doe", msg.ToName) + assert.Equal(t, "john.doe@example.com", msg.ToAddress) + assert.Equal(t, "Getting involved with Direct Action Everywhere", msg.Subject) + assert.Contains(t, msg.BodyHTML, "Hi John,") + assert.Contains(t, msg.BodyHTML, "I am part of the international coordination (IC) team") + }) + }) + + t.Run("ForCaParticipantNotNearAnyChapter", func(t *testing.T) { + t.Run("ContainsBasicInfo", func(t *testing.T) { + // Arrange + formData := testfixtures.NewInternationalFormDataBuilder(). + WithFirstName("John"). + WithLastName("Doe"). + WithEmail("john.doe@example.com"). + WithInvolvement("participate"). + WithState("CA"). + Build() + + // Act + msg, err := buildOnboardingEmailMessage(formData, nil, nil) + + // Assert + assert.NoError(t, err) + assert.NotNil(t, msg) + assert.Equal(t, californiaCoordinator.Name, msg.FromName) + assert.Equal(t, californiaCoordinator.Address, msg.FromAddress) + assert.Contains(t, msg.BCC, californiaCoordinator.Address) + assert.Equal(t, "John Doe", msg.ToName) + assert.Equal(t, "john.doe@example.com", msg.ToAddress) + assert.Equal(t, "Getting involved with Direct Action Everywhere", msg.Subject) + assert.Contains(t, msg.BodyHTML, "Hi John,") + assert.Contains(t, msg.BodyHTML, "Network Member Program") + }) + }) + + t.Run("ForNonCaParticipantNotNearAnyChapter", func(t *testing.T) { + t.Run("ContainsBasicInfo", func(t *testing.T) { + // Arrange + formData := testfixtures.NewInternationalFormDataBuilder(). + WithFirstName("John"). + WithLastName("Doe"). + WithEmail("john.doe@example.com"). + WithInvolvement("participate"). + WithState("ZZ"). + Build() + + // Act + msg, err := buildOnboardingEmailMessage(formData, nil, nil) + + // Assert + assert.NoError(t, err) + assert.NotNil(t, msg) + assert.Equal(t, globalCoordinator.Name, msg.FromName) + assert.Equal(t, globalCoordinator.Address, msg.FromAddress) + assert.Contains(t, msg.BCC, globalCoordinator.Address) + assert.Equal(t, "John Doe", msg.ToName) + assert.Equal(t, "john.doe@example.com", msg.ToAddress) + assert.Equal(t, "Getting involved with Direct Action Everywhere", msg.Subject) + assert.Contains(t, msg.BodyHTML, "Hi John,") + assert.Contains(t, msg.BodyHTML, "Network Member Program") + }) + }) +} diff --git a/server/src/international_application_processor/onboarding_message_builder.go b/server/src/international_application_processor/onboarding_message_builder.go new file mode 100644 index 00000000..f6b73172 --- /dev/null +++ b/server/src/international_application_processor/onboarding_message_builder.go @@ -0,0 +1,111 @@ +package international_application_processor + +import ( + "github.com/dxe/adb/mailer" + "github.com/dxe/adb/model" + "github.com/pkg/errors" +) + +type onboardingEmailMessageBuilder struct { + chapter *model.ChapterWithToken + nextEvent *model.ExternalEvent + firstName string // Sanitized (but not HTML-escaped) + fullName string // Sanitized (but not HTML-escaped) + email string // Format validated + state string // Sanitized + involvementUnsanitized string +} + +// onboardingEmailType represents the email templates used to email the +// responder. Each template is specific to factors such as the responder's +// location, nearby chapter, and desired involvement. +type onboardingEmailType int + +const ( + nearSFBayChapter onboardingEmailType = iota + nearNonSFBayChapter + caOrganizerNotNearAnyChapter + nonCaOrganizerNotNearAnyChapter + caParticipantNotNearAnyChapter + nonCaParticipantNotNearAnyChapter +) + +func buildOnboardingEmailMessage(formData model.InternationalFormData, chapter *model.ChapterWithToken, nextEvent *model.ExternalEvent) (*mailer.Message, error) { + firstName := sanitizeAndFormatName(formData.FirstName) + fullName := firstName + " " + sanitizeAndFormatName(formData.LastName) + + // Ensure user-provided email is nothing more than a normal email since this + // value is injected directly into email headers we send. + if err := validateEmail(formData.Email); err != nil { + return nil, errors.Wrapf(err, "invalid email address: %v", formData.Email) + } + email := formData.Email + + state := sanitizeState(formData.State) + involvementUnsanitized := formData.Involvement + + builder := onboardingEmailMessageBuilder{ + chapter, + nextEvent, + firstName, + fullName, + email, + state, + involvementUnsanitized, + } + + msg, err := builder.build() + return msg, err +} + +func (b *onboardingEmailMessageBuilder) build() (*mailer.Message, error) { + emailType := b.getOnboardingEmailType() + + var builders = map[onboardingEmailType]func(*onboardingEmailMessageBuilder) (mailer.Message, error){ + nearSFBayChapter: (*onboardingEmailMessageBuilder).nearSFBayChapter, + nearNonSFBayChapter: (*onboardingEmailMessageBuilder).nearNonSFBayChapter, + caOrganizerNotNearAnyChapter: (*onboardingEmailMessageBuilder).caOrganizerNotNearAnyChapter, + caParticipantNotNearAnyChapter: (*onboardingEmailMessageBuilder).caParticipantNotNearAnyChapter, + nonCaOrganizerNotNearAnyChapter: (*onboardingEmailMessageBuilder).nonCaOrganizerNotNearAnyChapter, + nonCaParticipantNotNearAnyChapter: (*onboardingEmailMessageBuilder).nonCaParticipantNotNearAnyChapter, + } + + builder, found := builders[emailType] + if !found { + return nil, errors.Errorf("no builder found for email type %v", emailType) + } + + msg, err := builder(b) + if err != nil { + return nil, err + } + + // Always BCC the sender so they: + // * Can follow up if there are no replies + // * See that the emails are getting sent out successfully + // * Can report any outdated info + msg.BCC = append(msg.BCC, msg.FromAddress) + + return &msg, nil +} + +func (b *onboardingEmailMessageBuilder) getOnboardingEmailType() onboardingEmailType { + if b.chapter != nil { + if b.chapter.ChapterID == model.SFBayChapterId { + return nearSFBayChapter + } + return nearNonSFBayChapter + } + + if b.involvementUnsanitized == "organize" { + if stateIsCalifornia(b.state) { + return caOrganizerNotNearAnyChapter + } + return nonCaOrganizerNotNearAnyChapter + } + + if stateIsCalifornia(b.state) { + return caParticipantNotNearAnyChapter + } + return nonCaParticipantNotNearAnyChapter +} diff --git a/server/src/international_application_processor/onboarding_messages.go b/server/src/international_application_processor/onboarding_messages.go new file mode 100644 index 00000000..9f0b231d --- /dev/null +++ b/server/src/international_application_processor/onboarding_messages.go @@ -0,0 +1,326 @@ +package international_application_processor + +import ( + "fmt" + "html" + "strings" + + "github.com/dxe/adb/mailer" + "github.com/dxe/adb/model" +) + +const almiraTannerSignatureHtml = ` +

+ Almira Tanner
+ Lead Organizer
+ Direct Action Everywhere
+ she/her +

+ ` + +func (b *onboardingEmailMessageBuilder) nearSFBayChapter() (mailer.Message, error) { + var msg mailer.Message + msg.FromName = sfBayCoordinator.Name + msg.FromAddress = sfBayCoordinator.Address + msg.ToName = b.fullName + msg.ToAddress = b.email + msg.Subject = "Join your local Direct Action Everywhere chapter!" + + var body strings.Builder + + fmt.Fprintf(&body, "

Hey %v!

", html.EscapeString(b.firstName)) + + body.WriteString( + `

+ I wanted to reach out about your inquiry of getting involved with + DxE. + The DxE SF Bay Area chapter is within 100 miles of you. + You can check out dxe.io/events + for a variety of different events happening local to you, from + community events to actions, so you can get involved and start + taking action with us! + You can also apply to be a chapter member at + dxe.io/apply. +

`) + + nextEventLink := getFacebookEventLinkOrEmptyString(b.nextEvent) + if len(nextEventLink) > 0 { + fmt.Fprintf( + &body, + "

You can also find details of our next event here: %v

", + nextEventLink) + } + + body.WriteString( + `

+ In the meantime you can + sign a letter to support the right to rescue. +

+ +

Let me know if you have any questions.

+ +

Hope that you can join us!

`) + + fmt.Fprintf(&body, + `

%v
+ DxE Organizer

`, + sfBayCoordinator.Name) + + msg.BodyHTML = body.String() + + return msg, nil +} + +func (b *onboardingEmailMessageBuilder) nearNonSFBayChapter() (mailer.Message, error) { + var msg mailer.Message + msg.FromName = globalCoordinator.Name + msg.FromAddress = globalCoordinator.Address + msg.ToName = b.fullName + msg.ToAddress = b.email + msg.CC = getChapterEmailsWithFallback(b.chapter, getChapterEmailFallback(b.state)) + msg.Subject = "Join your local Direct Action Everywhere chapter!" + + var body strings.Builder + + fmt.Fprintf(&body, "

Hey %v!

", html.EscapeString(b.firstName)) + + body.WriteString(` +

+ I wanted to reach out about your inquiry of getting involved with + DxE’s international network. + There is currently a DxE chapter near you, so I’ve included their + information and contact below so you can reach out, get involved, + and start taking action with them! +

+ `) + + body.WriteString("

") + if b.chapter.FbURL != "" { + fmt.Fprintf(&body, `%v Facebook page
`, + b.chapter.FbURL, b.chapter.Name) + } + if b.chapter.InstaURL != "" { + fmt.Fprintf(&body, `%v Instagram
`, + b.chapter.InstaURL, b.chapter.Name) + } + if b.chapter.TwitterURL != "" { + fmt.Fprintf(&body, `%v Twitter
`, + b.chapter.TwitterURL, b.chapter.Name) + } + if b.chapter.Email != "" { + fmt.Fprintf(&body, `%v Email
`, + b.chapter.Email, b.chapter.Name) + } + body.WriteString("

") + + body.WriteString( + `

+ I’ve also cc'd the organizers in your local chapter on this email, + so you can both be in contact. +

`) + + nextEventLink := getFacebookEventLinkOrEmptyString(b.nextEvent) + if len(nextEventLink) > 0 { + fmt.Fprintf( + &body, + "

You can also find details of our next event here: %v

", + nextEventLink) + } + + body.WriteString(` +

+ In the meantime you can + sign a letter to support the + right to rescue. +

+

+ Let me know if you have any questions or if you still haven't been + able to connect with your local chapter. +

+

Hope that you can join us!

+ `) + fmt.Fprintf(&body, ` +

+ %v
+ International Coordinator
+ Direct Action Everywhere +

`, + globalCoordinator.Name) + + msg.BodyHTML = body.String() + + return msg, nil +} + +func (b *onboardingEmailMessageBuilder) caOrganizerNotNearAnyChapter() (mailer.Message, error) { + var msg mailer.Message + msg.FromName = californiaCoordinator.Name + msg.FromAddress = californiaCoordinator.Address + msg.ToName = b.fullName + msg.ToAddress = b.email + + msg.Subject = "Getting involved with Direct Action Everywhere" + + var body strings.Builder + + fmt.Fprintf(&body, `

Hi %v,

`, html.EscapeString(b.firstName)) + + body.WriteString(` +

+ Thank you for your interest in joining the DxE Network. + Right now, DxE is actively seeking new organizers and chapters in + California. + There is no active chapter in your area and I am excited to help you + launch one! +

`) + fmt.Fprintf(&body, ` +

+ To begin, please find 1-2 other people in your area who might be + interested in helping you organize a chapter. Once you have + identified them, reach out to me by email at %v to schedule a call. + Don’t hesitate to email me with any questions in the meantime. + I’m looking forward to hearing back from you, +

`, californiaCoordinator.Address) + + body.WriteString(almiraTannerSignatureHtml) + + msg.BodyHTML = body.String() + + return msg, nil +} + +func (b *onboardingEmailMessageBuilder) nonCaOrganizerNotNearAnyChapter() (mailer.Message, error) { + var msg mailer.Message + msg.FromName = globalCoordinator.Name + msg.FromAddress = globalCoordinator.Address + msg.ToName = b.fullName + msg.ToAddress = b.email + msg.Subject = "Getting involved with Direct Action Everywhere" + + var body strings.Builder + fmt.Fprintf(&body, `

Hi %v,

`, html.EscapeString(b.firstName)) + + body.WriteString(` +

+ Thank you for your interest in joining the DxE Network. + I am part of the international coordination (IC) team and I am here + to help you start a DxE chapter in your area. Our onboarding process + involves four key steps: +

+
    +
  1. + Finding 1-2 other people in your area who are interested in + helping start the chapter, and then setting up a call with a + member of the IC team. During this call, we will explain the + whole onboarding process and you will also be assigned a mentor. +
  2. +
  3. + Completing 5 short training sessions that cover important + information about DxE and how to organize your first events. +
  4. +
  5. + Organizing your first action and community event. Don’t worry, + you will have a lot of time and support to make this happen! +
  6. +
  7. + Debriefing your action with your mentor and completing the + final onboarding steps for you and your chapter. At that point, + you will be an official DxE organizer in an official DxE + chapter! +
  8. +
+ +

+ To begin, please find 1-2 other people in your area who are + interested in helping you start a DxE chapter. Once you have + identified them, reach out to me by email to schedule our first + call. + Don’t hesitate to email me with any questions. + I’m looking forward to hearing back from you. +

+ `) + + fmt.Fprintf(&body, ` +

+ %v
+ International Coordinator + Direct Action Everywhere +

`, + globalCoordinator.Name) + + msg.BodyHTML = body.String() + + return msg, nil +} + +const networkMemberProgramIntroHtml = ` +

+ Thank you for your interest in joining the DxE Network. + We recently launched the Network Member Program which is a platform + for people who are interested in taking action with DxE but don’t + have a chapter near them or have the capacity to organize a chapter + themselves. + The only steps to complete to officially become a member are: +

+
    +
  1. Watch a video
  2. +
  3. Complete a short quiz
  4. +
+

+ This program offers the opportunity to connect with other activists + around the US and the world, join DxE’s campaigns, receive mentoring + to develop multiple skills and financial support to attend events + and actions in person. +

+ ` + +func (b *onboardingEmailMessageBuilder) caParticipantNotNearAnyChapter() (mailer.Message, error) { + var msg mailer.Message + msg.FromName = californiaCoordinator.Name + msg.FromAddress = californiaCoordinator.Address + msg.ToName = b.fullName + msg.ToAddress = b.email + msg.Subject = "Getting involved with Direct Action Everywhere" + + var body strings.Builder + fmt.Fprintf(&body, `

Hi %v,

`, html.EscapeString(b.firstName)) + body.WriteString(networkMemberProgramIntroHtml) + body.WriteString(almiraTannerSignatureHtml) + msg.BodyHTML = body.String() + + return msg, nil +} + +func (b *onboardingEmailMessageBuilder) nonCaParticipantNotNearAnyChapter() (mailer.Message, error) { + var msg mailer.Message + msg.FromName = globalCoordinator.Name + msg.FromAddress = globalCoordinator.Address + msg.ToName = b.fullName + msg.ToAddress = b.email + msg.Subject = "Getting involved with Direct Action Everywhere" + + var body strings.Builder + fmt.Fprintf(&body, `

Hi %v,

`, html.EscapeString(b.firstName)) + + body.WriteString(networkMemberProgramIntroHtml) + + fmt.Fprintf(&body, ` +

+ %v
+ International Coordinator
+ Direct Action Everywhere +

`, + globalCoordinator.Name) + + msg.BodyHTML = body.String() + + return msg, nil +} + +func getFacebookEventLinkOrEmptyString(event *model.ExternalEvent) string { + if event == nil || len(event.ID) == 0 { + return "" + } + + return fmt.Sprintf(`%v`, event.ID, event.Name) +} diff --git a/server/src/international_application_processor/process_submissions.go b/server/src/international_application_processor/process_submissions.go new file mode 100644 index 00000000..c06566a6 --- /dev/null +++ b/server/src/international_application_processor/process_submissions.go @@ -0,0 +1,108 @@ +package international_application_processor + +import ( + "log" + "time" + + "github.com/dxe/adb/model" + "github.com/pkg/errors" + + "github.com/jmoiron/sqlx" +) + +// Sends emails every 60 minutes. +// Should be run in a goroutine. +func RunProcessor(db *sqlx.DB) { + for { + log.Println("Starting international mailer") + processFormSubmissions(db) + log.Println("Finished international mailer") + + time.Sleep(60 * time.Minute) + } +} + +func processFormSubmissions(db *sqlx.DB) { + defer func() { + if r := recover(); r != nil { + log.Println("Recovered from panic in international mailer:", r) + } + }() + + records, err := model.GetInternationalFormSubmissionsToEmail(db) + if err != nil { + log.Println("Failed to get int'l form submissions to email:", err) + } + log.Printf("Int'l form mailer found %d records to process", len(records)) + + for _, rec := range records { + if err := processFormSubmission(db, rec); err != nil { + log.Printf("Error processing int'l form submission with ID %v: %v", rec.ID, err) + } + } +} + +func processFormSubmission(db *sqlx.DB, formData model.InternationalFormData) error { + chapter, err := getNearestChapterOrNil(db, formData) + if err != nil { + return errors.Wrap(err, "error getting nearest chapter") + } + + // Email the person who submitted the form. + err = sendOnboardingEmail(db, formData, chapter) + if err != nil { + return errors.Wrap(err, "failed to send onboarding email") + } + + // Email relevant existing organizers to notify them of the form submission. + err = sendNotificationEmail(formData, chapter) + if err != nil { + return errors.Wrap(err, "failed to send notification email") + } + + // Mark submission as procesed. + err = model.UpdateInternationalFormSubmissionEmailStatus(db, formData.ID) + if err != nil { + return errors.Wrap(err, "error updating status: %w") + } + + return nil +} + +func getNearestChapterOrNil(db *sqlx.DB, formData model.InternationalFormData) (*model.ChapterWithToken, error) { + nearestChapters, err := model.FindNearestChaptersSortedByDistance(db, formData.Lat, formData.Lng) + if err != nil { + return nil, errors.Wrap(err, "error fetching nearby chapters: %w") + } + + nearestChapter := pickNearestChapterOrNil(nearestChapters, formData.Country) + + if nearestChapter != nil { + // `GetChapterByID` returns more details about the chapter than `FindNearestChapters`. + *nearestChapter, err = model.GetChapterByID(db, nearestChapter.ChapterID) + if err != nil { + return nil, errors.Wrap(err, "error fetching chapter: %w") + } + } + + return nearestChapter, nil +} + +// pickNearestChapterOrNil picks the first chapter that is within 100 miles and in +// the same country. +// +// `nearestChapters` MUST be sorted by distance in ascending order. +func pickNearestChapterOrNil(nearestChapters []model.ChapterWithToken, country string) *model.ChapterWithToken { + for _, chapter := range nearestChapters { + if chapter.Distance > 100 { + break + } + if chapter.Country != country { + continue + } + + return &chapter + } + + return nil +} diff --git a/server/src/international_application_processor/process_submissions_test.go b/server/src/international_application_processor/process_submissions_test.go new file mode 100644 index 00000000..9135fd30 --- /dev/null +++ b/server/src/international_application_processor/process_submissions_test.go @@ -0,0 +1,67 @@ +package international_application_processor + +import ( + "testing" + + "github.com/dxe/adb/model" + "github.com/stretchr/testify/assert" +) + +func TestPickNearestChapterOrNil(t *testing.T) { + // Define test cases + tests := []struct { + name string + nearestChapters []model.ChapterWithToken + country string + expectedChapter *model.ChapterWithToken + }{ + { + name: "Select nearest chapter", + nearestChapters: []model.ChapterWithToken{ + {ChapterID: 1, Distance: 50, Country: "US"}, + {ChapterID: 2, Distance: 80, Country: "US"}, + }, + country: "US", + expectedChapter: &model.ChapterWithToken{ + ChapterID: 1, Distance: 50, Country: "US", + }, + }, + { + name: "No chapter within 100 miles", + nearestChapters: []model.ChapterWithToken{ + {ChapterID: 1, Distance: 150, Country: "US"}, + {ChapterID: 2, Distance: 200, Country: "US"}, + }, + country: "US", + expectedChapter: nil, + }, + { + name: "No matching country", + nearestChapters: []model.ChapterWithToken{ + {ChapterID: 1, Distance: 50, Country: "CA"}, + {ChapterID: 2, Distance: 80, Country: "CA"}, + }, + country: "US", + expectedChapter: nil, + }, + { + name: "Skip different country", + nearestChapters: []model.ChapterWithToken{ + {ChapterID: 1, Distance: 10, Country: "CA"}, + {ChapterID: 3, Distance: 20, Country: "US"}, + {ChapterID: 2, Distance: 30, Country: "US"}, + }, + country: "US", + expectedChapter: &model.ChapterWithToken{ + ChapterID: 3, Distance: 20, Country: "US", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := pickNearestChapterOrNil(tt.nearestChapters, tt.country) + assert.Equal(t, tt.expectedChapter, result) + }) + } +} diff --git a/server/src/international_mailer/international_mailer.go b/server/src/international_mailer/international_mailer.go deleted file mode 100644 index d0186488..00000000 --- a/server/src/international_mailer/international_mailer.go +++ /dev/null @@ -1,234 +0,0 @@ -// Please keep in sync with -// https://docs.google.com/document/d/1tgtxGONu86XN0KBvOx9-OXmh3mDKpvzkAyQGFoHOdkM/edit?tab=t.0#heading=h.qsu6qdtc3163 -// -// Documentation: -// https://coda.io/d/Tech-Team_dR-UIgVShEf/ADB-Forms_suuKCpXS - -package international_mailer - -import ( - "fmt" - "log" - "strings" - "time" - - "github.com/dxe/adb/mailer" - "github.com/dxe/adb/model" - - "github.com/jmoiron/sqlx" -) - -func processFormSubmission(db *sqlx.DB, formData model.InternationalFormData) { - nearestChapters, err := model.FindNearestChapters(db, formData.Lat, formData.Lng) - if err != nil { - panic(err) - } - - var nearestChapter *model.ChapterWithToken - for _, chapter := range nearestChapters { - if chapter.Distance > 150 { - break - } - if chapter.Country == formData.Country { - nearestChapter = &chapter - break - } - } - - err = sendInternationalOnboardingEmail(db, formData, nearestChapter) - if err != nil { - panic(err) - } - - err = model.UpdateInternationalFormSubmissionEmailStatus(db, formData.ID) - if err != nil { - log.Println("Error updating international form submission email status") - } -} - -func sendInternationalOnboardingEmail(db *sqlx.DB, formData model.InternationalFormData, chapter *model.ChapterWithToken) error { - var msg mailer.Message - msg.FromName = "Michelle Del Cueto" - msg.FromAddress = "internationalcoordination@directactioneverywhere.com" - msg.ToName = formData.FirstName + " " + formData.LastName - msg.ToAddress = formData.Email - - switch chapter != nil { - case true: - if chapter.Name == "SF Bay Area" { - return nil - } - - // append CC list - if chapter.Email != "" { - msg.CC = append(msg.CC, chapter.Email) - } - chapterDetails, err := model.GetChapterByID(db, chapter.ChapterID) - if err != nil { - panic(err) - } - organizers := chapterDetails.Organizers - if len(organizers) > 0 { - for _, o := range organizers { - if o.Email != "" { - msg.CC = append(msg.CC, o.Email) - } - } - } - - err = sendInternationalAlertEmail(formData, msg.CC) - if err != nil { - log.Printf("Error sending int'l alert email: %v\n", err.Error()) - } - - // build contact info links - var contactInfo string - socialLinks := map[string]string{ - "Facebook page": chapter.FbURL, - "Instagram": chapter.InstaURL, - "Twitter": chapter.TwitterURL, - "Email": chapter.Email, - } - for k, v := range socialLinks { - if v != "" { - contactInfo += fmt.Sprintf(`%v %v
`, v, chapter.Name, k) - } - } - - // check if chapter has an upcoming event - var nextEvent model.ExternalEvent - if chapter.ID != 0 { - startTime := time.Now() - endTime := time.Now().Add(60 * 24 * time.Hour) - events, _ := model.GetExternalEvents(db, chapter.ID, startTime, endTime) - if len(events) > 0 { - nextEvent = events[0] - } - } - - // assemble the message - msg.Subject = "Join your local Direct Action Everywhere chapter!" - msg.BodyHTML = ` -

Hey ` + strings.Title(strings.TrimSpace(formData.FirstName)) + `!

-

- I wanted to reach out about your inquiry of getting involved with DxE’s international network. There is - currently a DxE chapter near you, so I’ve included their information and contact below so you can reach out, - get involved, and start taking action with them! - -

-

- ` + contactInfo + ` -

-

I’ve also cc'd the organizers in your local chapter on this email, so you can both be in contact.

- ` - - if len(nextEvent.ID) != 0 { - msg.BodyHTML += fmt.Sprintf(` -

You can also find details of their next event here: %v.

- `, nextEvent.ID, nextEvent.Name) - } - - msg.BodyHTML += ` -

- In the meantime you can - sign a letter to support the right to rescue. -

-

Let me know if you have any questions or if you still haven't been able to connect with your local chapter.

-

Hope that you can join us!

-

- Michelle Del Cueto
- International Coordinator
- Direct Action Everywhere -

- ` - - default: - msg.Subject = "Getting involved with Direct Action Everywhere" - msg.BodyHTML = ` -

Hi ` + strings.Title(strings.TrimSpace(formData.FirstName)) + `,

-

Thank you for your interest in becoming a DxE organizer. We are currently revamping - the onboarding process to make it more effective and engaging for everyone. At the moment, - the next step you can take is to - watch this video that talks more in depth about DxE's theory of change. Then, find other - two people in your area that are interested in taking action together and email me at - michelle@dxe.io so we can set up a call together.

-

- Michelle Del Cueto
- International Coordinator
- Direct Action Everywhere -

- ` - } - - log.Printf("Int'l mailer sending email to %v\n", formData.Email) - - err := mailer.Send(msg) - if err != nil { - log.Println("Failed to send email for international form submission") - } - - return nil -} - -func sendInternationalAlertEmail(formData model.InternationalFormData, to []string) error { - if len(to) == 0 { - return nil - } - - msg := mailer.Message{ - FromName: "DxE Join Form", - FromAddress: "noreply@directactioneverywhere.com", - ToAddress: to[0], - Subject: fmt.Sprintf("%v %v signed up to join your chapter", formData.FirstName, formData.LastName), - } - - if len(to) > 1 { - msg.CC = to[1:] - } - - msg.BodyHTML = fmt.Sprintf(` -

Name: %v %v

-

Email: %v

-

Phone: %v

-

City: %v

-

Involvement: %v

-

Skills: %v

- `, formData.FirstName, formData.LastName, formData.Email, formData.Phone, formData.City, formData.Involvement, formData.Skills) - - log.Println("Int'l mailer sending alert email") - err := mailer.Send(msg) - if err != nil { - log.Println("Failed to send int'l alert email") - } - return nil -} - -func internationalMailerWrapper(db *sqlx.DB) { - defer func() { - if r := recover(); r != nil { - log.Println("Recovered from panic in international mailer", r) - } - }() - - records, err := model.GetInternationalFormSubmissionsToEmail(db) - if err != nil { - panic("Failed to get int'l form submissions to email " + err.Error()) - } - log.Printf("Int'l form mailer found %d records to process\n", len(records)) - - for _, rec := range records { - processFormSubmission(db, rec) - } -} - -// Sends emails every 60 minutes. -// Should be run in a goroutine. -func StartInternationalMailer(db *sqlx.DB) { - for { - log.Println("Starting international mailer") - internationalMailerWrapper(db) - log.Println("Finished international mailer") - - time.Sleep(60 * time.Minute) - } -} diff --git a/server/src/mailer/mailer.go b/server/src/mailer/mailer.go index 2b00f3a6..e8e6e302 100644 --- a/server/src/mailer/mailer.go +++ b/server/src/mailer/mailer.go @@ -19,6 +19,7 @@ type Message struct { ReplyToAddress string ReplyToAddresses []string CC []string + BCC []string } func smtpConfigValid() bool { @@ -80,9 +81,8 @@ func Send(e Message) error { message := headers + "\n" + body sendTo := []string{e.ToAddress} - if len(e.CC) > 0 { - sendTo = append(sendTo, e.CC...) - } + sendTo = append(sendTo, e.CC...) + sendTo = append(sendTo, e.BCC...) if err := smtp.SendMail(host+":"+port, auth, e.FromAddress, sendTo, []byte(message)); err != nil { return errors.Wrap(err, "failed to send email") diff --git a/server/src/main.go b/server/src/main.go index 8958582c..72a6da66 100644 --- a/server/src/main.go +++ b/server/src/main.go @@ -20,10 +20,9 @@ import ( "strings" "time" + "github.com/dxe/adb/international_application_processor" "github.com/dxe/adb/members" - "github.com/dxe/adb/international_mailer" - oidc "github.com/coreos/go-oidc" "github.com/dxe/adb/config" "github.com/dxe/adb/discord" @@ -1848,7 +1847,7 @@ func (c MainController) FindNearestChaptersHandler(w http.ResponseWriter, r *htt } // run query - pages, err := model.FindNearestChapters(c.db, lat, lng) + pages, err := model.FindNearestChaptersSortedByDistance(c.db, lat, lng) if err != nil { panic(err) } @@ -2536,7 +2535,7 @@ func main() { if config.RunBackgroundJobs { go google_groups_sync.StartMailingListsSync(db) go survey_mailer.StartSurveyMailer(db) - go international_mailer.StartInternationalMailer(db) + go international_application_processor.RunProcessor(db) go event_sync.StartExternalEventSync(db) go form_processor.StartFormProcessor(db) } diff --git a/server/src/model/chapters.go b/server/src/model/chapters.go index 06eab3e5..0657260e 100644 --- a/server/src/model/chapters.go +++ b/server/src/model/chapters.go @@ -32,7 +32,7 @@ type Chapter struct { // used for internal Chapters page on ADB, syncing with FB and Eventbrite, and for displaying events on the website type ChapterWithToken struct { - ID int `db:"id"` + ID int `db:"id"` // id on `fb_pages` table ChapterID int `db:"chapter_id"` Name string `db:"name"` Flag string `db:"flag"` @@ -278,7 +278,7 @@ func CleanChapterData(db *sqlx.DB, body io.Reader) (ChapterWithToken, error) { } // TODO: update this function (and the website) to handle data in the normal Chapter struct instead of w/ Token -func FindNearestChapters(db *sqlx.DB, lat float64, lng float64) ([]ChapterWithToken, error) { +func FindNearestChaptersSortedByDistance(db *sqlx.DB, lat float64, lng float64) ([]ChapterWithToken, error) { query := `SELECT id, chapter_id, name, email, flag, fb_url, insta_url, twitter_url, region, country, ml_type, ml_radius, ml_id, (3959*acos(cos(radians(` + fmt.Sprintf("%f", lat) + `))*cos(radians(lat))* cos(radians(lng)-radians(` + fmt.Sprintf("%f", lng) + `))+sin(radians(` + fmt.Sprintf("%f", lat) + `))* sin(radians(lat)))) AS distance diff --git a/server/src/testfixtures/chapter_utils.go b/server/src/testfixtures/chapter_utils.go new file mode 100644 index 00000000..3314e204 --- /dev/null +++ b/server/src/testfixtures/chapter_utils.go @@ -0,0 +1,181 @@ +package testfixtures + +import "github.com/dxe/adb/model" + +type ChapterBuilder struct { + chapter model.ChapterWithToken +} + +func NewChapterBuilder() *ChapterBuilder { + return &ChapterBuilder{ + chapter: model.ChapterWithToken{ + ID: 98240, + ChapterID: 28992, + Name: "Foo Chapter", + Flag: "default_flag", + FbURL: "https://facebook.com/dxe-foo", + TwitterURL: "https://twitter.com/dxe-foo", + InstaURL: "https://instagram.com/dxe-foo", + Email: "dxe-foo@example.com", + Region: "Default Region", + Lat: 37.7749, + Lng: -122.4194, + Distance: 10.0, + MailingListType: "sendgrid", + MailingListRadius: 50, + MailingListID: "999999389248", + Token: "default_token", + LastFBSync: "2025-01-01", + LastFBEvent: "2025-01-01", + EventbriteID: "default_eventbrite_id", + EventbriteToken: "default_eventbrite_token", + Mentor: "Mentor Anne", + Country: "US", + Notes: "some nice notes", + LastContact: "2025-01-01", + LastAction: "2025-01-01", + Organizers: model.Organizers{}, + EmailToken: "abcd00244", + }, + } +} + +func (b *ChapterBuilder) WithFbPagesTableID(id int) *ChapterBuilder { + // Not to be confused with ChapterID. + b.chapter.ID = id + return b +} + +func (b *ChapterBuilder) WithChapterID(chapterID int) *ChapterBuilder { + b.chapter.ChapterID = chapterID + return b +} + +func (b *ChapterBuilder) WithName(name string) *ChapterBuilder { + b.chapter.Name = name + return b +} + +func (b *ChapterBuilder) WithFlag(flag string) *ChapterBuilder { + b.chapter.Flag = flag + return b +} + +func (b *ChapterBuilder) WithFbURL(fbURL string) *ChapterBuilder { + b.chapter.FbURL = fbURL + return b +} + +func (b *ChapterBuilder) WithTwitterURL(twitterURL string) *ChapterBuilder { + b.chapter.TwitterURL = twitterURL + return b +} + +func (b *ChapterBuilder) WithInstaURL(instaURL string) *ChapterBuilder { + b.chapter.InstaURL = instaURL + return b +} + +func (b *ChapterBuilder) WithEmail(email string) *ChapterBuilder { + b.chapter.Email = email + return b +} + +func (b *ChapterBuilder) WithRegion(region string) *ChapterBuilder { + b.chapter.Region = region + return b +} + +func (b *ChapterBuilder) WithLat(lat float64) *ChapterBuilder { + b.chapter.Lat = lat + return b +} + +func (b *ChapterBuilder) WithLng(lng float64) *ChapterBuilder { + b.chapter.Lng = lng + return b +} + +func (b *ChapterBuilder) WithDistance(distance float32) *ChapterBuilder { + b.chapter.Distance = distance + return b +} + +func (b *ChapterBuilder) WithMailingListType(mlType string) *ChapterBuilder { + b.chapter.MailingListType = mlType + return b +} + +func (b *ChapterBuilder) WithMailingListRadius(mlRadius int) *ChapterBuilder { + b.chapter.MailingListRadius = mlRadius + return b +} + +func (b *ChapterBuilder) WithMailingListID(mlID string) *ChapterBuilder { + b.chapter.MailingListID = mlID + return b +} + +func (b *ChapterBuilder) WithToken(token string) *ChapterBuilder { + b.chapter.Token = token + return b +} + +func (b *ChapterBuilder) WithLastFBSync(lastFBSync string) *ChapterBuilder { + b.chapter.LastFBSync = lastFBSync + return b +} + +func (b *ChapterBuilder) WithLastFBEvent(lastFBEvent string) *ChapterBuilder { + b.chapter.LastFBEvent = lastFBEvent + return b +} + +func (b *ChapterBuilder) WithEventbriteID(eventbriteID string) *ChapterBuilder { + b.chapter.EventbriteID = eventbriteID + return b +} + +func (b *ChapterBuilder) WithEventbriteToken(eventbriteToken string) *ChapterBuilder { + b.chapter.EventbriteToken = eventbriteToken + return b +} + +func (b *ChapterBuilder) WithMentor(mentor string) *ChapterBuilder { + b.chapter.Mentor = mentor + return b +} + +func (b *ChapterBuilder) WithCountry(country string) *ChapterBuilder { + b.chapter.Country = country + return b +} + +func (b *ChapterBuilder) WithNotes(notes string) *ChapterBuilder { + b.chapter.Notes = notes + return b +} + +func (b *ChapterBuilder) WithLastContact(lastContact string) *ChapterBuilder { + b.chapter.LastContact = lastContact + return b +} + +func (b *ChapterBuilder) WithLastAction(lastAction string) *ChapterBuilder { + b.chapter.LastAction = lastAction + return b +} + +func (b *ChapterBuilder) WithOrganizers(organizers model.Organizers) *ChapterBuilder { + b.chapter.Organizers = organizers + return b +} + +func (b *ChapterBuilder) WithEmailToken(emailToken string) *ChapterBuilder { + b.chapter.EmailToken = emailToken + return b +} + +func (b *ChapterBuilder) Build() model.ChapterWithToken { + return b.chapter +} diff --git a/server/src/testfixtures/form_data_utils.go b/server/src/testfixtures/form_data_utils.go new file mode 100644 index 00000000..675cb13b --- /dev/null +++ b/server/src/testfixtures/form_data_utils.go @@ -0,0 +1,94 @@ +package testfixtures + +import "github.com/dxe/adb/model" + +type InternationalFormDataBuilder struct { + formData model.InternationalFormData +} + +func NewInternationalFormDataBuilder() *InternationalFormDataBuilder { + return &InternationalFormDataBuilder{ + formData: model.InternationalFormData{ + ID: 1, + FirstName: "DefaultFirstName", + LastName: "DefaultLastName", + Email: "default@example.com", + Phone: "1234567890", + Interest: "DefaultInterest", + Skills: "DefaultSkills", + Involvement: "DefaultInvolvement", + City: "DefaultCity", + State: "DefaultState", + Country: "DefaultCountry", + Lat: 37.7749, Lng: -122.4194}, + } +} + +func (b *InternationalFormDataBuilder) WithID(id int) *InternationalFormDataBuilder { + b.formData.ID = id + return b +} + +func (b *InternationalFormDataBuilder) WithFirstName(firstName string) *InternationalFormDataBuilder { + b.formData.FirstName = firstName + return b +} + +func (b *InternationalFormDataBuilder) WithLastName(lastName string) *InternationalFormDataBuilder { + b.formData.LastName = lastName + return b +} + +func (b *InternationalFormDataBuilder) WithEmail(email string) *InternationalFormDataBuilder { + b.formData.Email = email + return b +} + +func (b *InternationalFormDataBuilder) WithPhone(phone string) *InternationalFormDataBuilder { + b.formData.Phone = phone + return b +} + +func (b *InternationalFormDataBuilder) WithInterest(interest string) *InternationalFormDataBuilder { + b.formData.Interest = interest + return b +} + +func (b *InternationalFormDataBuilder) WithSkills(skills string) *InternationalFormDataBuilder { + b.formData.Skills = skills + return b +} + +func (b *InternationalFormDataBuilder) WithInvolvement(involvement string) *InternationalFormDataBuilder { + b.formData.Involvement = involvement + return b +} + +func (b *InternationalFormDataBuilder) WithCity(city string) *InternationalFormDataBuilder { + b.formData.City = city + return b +} + +func (b *InternationalFormDataBuilder) WithState(state string) *InternationalFormDataBuilder { + b.formData.State = state + return b +} + +func (b *InternationalFormDataBuilder) WithCountry(country string) *InternationalFormDataBuilder { + b.formData.Country = country + return b +} + +func (b *InternationalFormDataBuilder) WithLat(lat float64) *InternationalFormDataBuilder { + b.formData.Lat = lat + return b +} + +func (b *InternationalFormDataBuilder) WithLng(lng float64) *InternationalFormDataBuilder { + b.formData.Lng = lng + return b +} + +func (b *InternationalFormDataBuilder) Build() model.InternationalFormData { + return b.formData +}