ente/server/pkg/controller/mailing_lists.go
2024-03-01 13:37:01 +05:30

161 lines
5.9 KiB
Go

package controller
import (
"fmt"
"net/url"
"strings"
"github.com/ente-io/museum/ente"
"github.com/ente-io/museum/pkg/external/zoho"
"github.com/ente-io/stacktrace"
log "github.com/sirupsen/logrus"
"github.com/spf13/viper"
)
// MailingListsController is used to keeping the external mailing lists in sync
// with customer email changes.
//
// MailingListsController contains methods for keeping external mailing lists in
// sync when new users sign up, or update their email, or delete their account.
// Currently, these mailing lists are hosted on Zoho Campaigns.
//
// See also: Syncing emails with Zoho Campaigns
type MailingListsController struct {
zohoAccessToken string
zohoListKey string
zohoTopicIds string
zohoCredentials zoho.Credentials
}
// Return a new instance of MailingListsController
func NewMailingListsController() *MailingListsController {
zohoCredentials := zoho.Credentials{
ClientID: viper.GetString("zoho.client-id"),
ClientSecret: viper.GetString("zoho.client-secret"),
RefreshToken: viper.GetString("zoho.refresh-token"),
}
// The Zoho "List Key" identifies a particular list of email IDs that are
// stored in Zoho. All the actions that we perform (adding, removing and
// updating emails) are done on this list.
//
// https://www.zoho.com/campaigns/help/developers/list-management.html
zohoListKey := viper.GetString("zoho.list-key")
// List of topics to which emails are sent.
//
// Ostensibly, we can get them from their API
// https://www.zoho.com/campaigns/oldhelp/api/get-topics.html
//
// But that doesn't currently work, luckily we can get these IDs by looking
// at the HTML source of the topic update dashboard page.
zohoTopicIds := viper.GetString("zoho.topic-ids")
// Zoho has a rate limit on the number of access tokens that can created
// within a given time period. So as an aid in debugging, allow the access
// token to be passed in. This will not be present in production - there
// we'll use the refresh token to create an access token on demand.
zohoAccessToken := viper.GetString("zoho.access_token")
return &MailingListsController{
zohoCredentials: zohoCredentials,
zohoListKey: zohoListKey,
zohoTopicIds: zohoTopicIds,
zohoAccessToken: zohoAccessToken,
}
}
// Add the given email address to our default Zoho Campaigns list.
//
// It is valid to resubscribe an email that has previously been unsubscribe.
//
// # Syncing emails with Zoho Campaigns
//
// Zoho Campaigns does not support maintaining a list of raw email addresses
// that can be later updated or deleted via their API. So instead, we maintain
// the email addresses of our customers in a Zoho Campaign "list", and subscribe
// or unsubscribe them to this list.
func (c *MailingListsController) Subscribe(email string) error {
if c.shouldSkip() {
return stacktrace.Propagate(ente.ErrNotImplemented, "")
}
// Need to set "Signup Form Disabled" in the list settings since we use this
// list to keep track of emails that have already been verified.
//
// > You can use this API to add contacts to your mailing lists. For signup
// form enabled mailing lists, the contacts will receive a confirmation
// email. For signup form disabled lists, contacts will be added without
// any confirmations.
//
// https://www.zoho.com/campaigns/help/developers/contact-subscribe.html
return c.doListAction("listsubscribe", email)
}
// Unsubscribe the given email address to our default Zoho Campaigns list.
//
// See: [Note: Syncing emails with Zoho Campaigns]
func (c *MailingListsController) Unsubscribe(email string) error {
if c.shouldSkip() {
return stacktrace.Propagate(ente.ErrNotImplemented, "")
}
// https://www.zoho.com/campaigns/help/developers/contact-unsubscribe.html
return c.doListAction("listunsubscribe", email)
}
func (c *MailingListsController) shouldSkip() bool {
if c.zohoCredentials.RefreshToken == "" {
log.Info("Skipping mailing list update because credentials are not configured")
return true
}
return false
}
// Both the listsubscribe and listunsubscribe Zoho Campaigns API endpoints work
// similarly, so use this function to keep the common code.
func (c *MailingListsController) doListAction(action string, email string) error {
// Query escape the email so that any pluses get converted to %2B.
escapedEmail := url.QueryEscape(email)
contactInfo := fmt.Sprintf("{Contact+Email: \"%s\"}", escapedEmail)
// Instead of using QueryEscape, use PathEscape. QueryEscape escapes the "+"
// character, which causes Zoho API to not recognize the parameter.
escapedContactInfo := url.PathEscape(contactInfo)
url := fmt.Sprintf(
"https://campaigns.zoho.com/api/v1.1/json/%s?resfmt=JSON&listkey=%s&contactinfo=%s&topic_id=%s",
action, c.zohoListKey, escapedContactInfo, c.zohoTopicIds)
zohoAccessToken, err := zoho.DoRequest("POST", url, c.zohoAccessToken, c.zohoCredentials)
c.zohoAccessToken = zohoAccessToken
if err != nil {
// This is not necessarily an error, and can happen when the customer
// had earlier unsubscribed from our organization emails in Zoho,
// selecting the "Erase my data" option. This causes Zoho to remove the
// customer's entire record from their database.
//
// Then later, say if the customer deletes their account from ente, we
// would try to unsubscribe their email but it wouldn't be present in
// Zoho, and this API call would've failed.
//
// In such a case, Zoho will return the following response:
//
// { code":"2103",
// "message":"Contact does not exist.",
// "version":"1.1",
// "uri":"/api/v1.1/json/listunsubscribe",
// "status":"error"}
//
// Special case these to reduce the severity level so as to not cause
// error log spam.
if strings.Contains(err.Error(), "Contact does not exist") {
log.Warnf("Zoho - Could not %s '%s': %s", action, email, err)
} else {
log.Errorf("Zoho - Could not %s '%s': %s", action, email, err)
}
}
return stacktrace.Propagate(err, "")
}