package mailgun import ( "context" "encoding/json" "errors" "fmt" "io" "strconv" "time" ) // MaxNumberOfRecipients represents the largest batch of recipients that Mailgun can support in a single API call. // This figure includes To:, Cc:, Bcc:, etc. recipients. const MaxNumberOfRecipients = 1000 // MaxNumberOfTags represents the maximum number of tags that can be added for a message const MaxNumberOfTags = 3 // Message structures contain both the message text and the envelop for an e-mail message. type Message struct { to []string tags []string campaigns []string dkim bool deliveryTime time.Time attachments []string readerAttachments []ReaderAttachment inlines []string readerInlines []ReaderAttachment bufferAttachments []BufferAttachment nativeSend bool testMode bool tracking bool trackingClicks bool trackingOpens bool headers map[string]string variables map[string]string templateVariables map[string]interface{} recipientVariables map[string]map[string]interface{} domain string dkimSet bool trackingSet bool trackingClicksSet bool trackingOpensSet bool requireTLS bool skipVerification bool specific features mg Mailgun } type ReaderAttachment struct { Filename string ReadCloser io.ReadCloser } type BufferAttachment struct { Filename string Buffer []byte } // StoredMessage structures contain the (parsed) message content for an email // sent to a Mailgun account. // // The MessageHeaders field is special, in that it's formatted as a slice of pairs. // Each pair consists of a name [0] and value [1]. Array notation is used instead of a map // because that's how it's sent over the wire, and it's how encoding/json expects this field // to be. type StoredMessage struct { Recipients string `json:"recipients"` Sender string `json:"sender"` From string `json:"from"` Subject string `json:"subject"` BodyPlain string `json:"body-plain"` StrippedText string `json:"stripped-text"` StrippedSignature string `json:"stripped-signature"` BodyHtml string `json:"body-html"` StrippedHtml string `json:"stripped-html"` Attachments []StoredAttachment `json:"attachments"` MessageUrl string `json:"message-url"` ContentIDMap map[string]struct { Url string `json:"url"` ContentType string `json:"content-type"` Name string `json:"name"` Size int64 `json:"size"` } `json:"content-id-map"` MessageHeaders [][]string `json:"message-headers"` } // StoredAttachment structures contain information on an attachment associated with a stored message. type StoredAttachment struct { Size int `json:"size"` Url string `json:"url"` Name string `json:"name"` ContentType string `json:"content-type"` } type StoredMessageRaw struct { Recipients string `json:"recipients"` Sender string `json:"sender"` From string `json:"from"` Subject string `json:"subject"` BodyMime string `json:"body-mime"` } // plainMessage contains fields relevant to plain API-synthesized messages. // You're expected to use various setters to set most of these attributes, // although from, subject, and text are set when the message is created with // NewMessage. type plainMessage struct { from string cc []string bcc []string subject string text string html string template string } // mimeMessage contains fields relevant to pre-packaged MIME messages. type mimeMessage struct { body io.ReadCloser } type sendMessageResponse struct { Message string `json:"message"` Id string `json:"id"` } // features abstracts the common characteristics between regular and MIME messages. // addCC, addBCC, recipientCount, and setHTML are invoked via the package-global AddCC, AddBCC, // RecipientCount, and SetHtml calls, as these functions are ignored for MIME messages. // Send() invokes addValues to add message-type-specific MIME headers for the API call // to Mailgun. isValid yeilds true if and only if the message is valid enough for sending // through the API. Finally, endpoint() tells Send() which endpoint to use to submit the API call. type features interface { addCC(string) addBCC(string) setHtml(string) addValues(*formDataPayload) isValid() bool endpoint() string recipientCount() int setTemplate(string) } // NewMessage returns a new e-mail message with the simplest envelop needed to send. // // Unlike the global function, // this method supports arbitrary-sized recipient lists by // automatically sending mail in batches of up to MaxNumberOfRecipients. // // To support batch sending, you don't want to provide a fixed To: header at this point. // Pass nil as the to parameter to skip adding the To: header at this stage. // You can do this explicitly, or implicitly, as follows: // // // Note absence of To parameter(s)! // m := mg.NewMessage("me@example.com", "Help save our planet", "Hello world!") // // Note that you'll need to invoke the AddRecipientAndVariables or AddRecipient method // before sending, though. func (mg *MailgunImpl) NewMessage(from, subject, text string, to ...string) *Message { return &Message{ specific: &plainMessage{ from: from, subject: subject, text: text, }, to: to, mg: mg, } } // NewMIMEMessage creates a new MIME message. These messages are largely canned; // you do not need to invoke setters to set message-related headers. // However, you do still need to call setters for Mailgun-specific settings. // // Unlike the global function, // this method supports arbitrary-sized recipient lists by // automatically sending mail in batches of up to MaxNumberOfRecipients. // // To support batch sending, you don't want to provide a fixed To: header at this point. // Pass nil as the to parameter to skip adding the To: header at this stage. // You can do this explicitly, or implicitly, as follows: // // // Note absence of To parameter(s)! // m := mg.NewMessage("me@example.com", "Help save our planet", "Hello world!") // // Note that you'll need to invoke the AddRecipientAndVariables or AddRecipient method // before sending, though. func (mg *MailgunImpl) NewMIMEMessage(body io.ReadCloser, to ...string) *Message { return &Message{ specific: &mimeMessage{ body: body, }, to: to, mg: mg, } } // AddReaderAttachment arranges to send a file along with the e-mail message. // File contents are read from a io.ReadCloser. // The filename parameter is the resulting filename of the attachment. // The readCloser parameter is the io.ReadCloser which reads the actual bytes to be used // as the contents of the attached file. func (m *Message) AddReaderAttachment(filename string, readCloser io.ReadCloser) { ra := ReaderAttachment{Filename: filename, ReadCloser: readCloser} m.readerAttachments = append(m.readerAttachments, ra) } // AddBufferAttachment arranges to send a file along with the e-mail message. // File contents are read from the []byte array provided // The filename parameter is the resulting filename of the attachment. // The buffer parameter is the []byte array which contains the actual bytes to be used // as the contents of the attached file. func (m *Message) AddBufferAttachment(filename string, buffer []byte) { ba := BufferAttachment{Filename: filename, Buffer: buffer} m.bufferAttachments = append(m.bufferAttachments, ba) } // AddAttachment arranges to send a file from the filesystem along with the e-mail message. // The attachment parameter is a filename, which must refer to a file which actually resides // in the local filesystem. func (m *Message) AddAttachment(attachment string) { m.attachments = append(m.attachments, attachment) } // AddReaderInline arranges to send a file along with the e-mail message. // File contents are read from a io.ReadCloser. // The filename parameter is the resulting filename of the attachment. // The readCloser parameter is the io.ReadCloser which reads the actual bytes to be used // as the contents of the attached file. func (m *Message) AddReaderInline(filename string, readCloser io.ReadCloser) { ra := ReaderAttachment{Filename: filename, ReadCloser: readCloser} m.readerInlines = append(m.readerInlines, ra) } // AddInline arranges to send a file along with the e-mail message, but does so // in a way that its data remains "inline" with the rest of the message. This // can be used to send image or font data along with an HTML-encoded message body. // The attachment parameter is a filename, which must refer to a file which actually resides // in the local filesystem. func (m *Message) AddInline(inline string) { m.inlines = append(m.inlines, inline) } // AddRecipient appends a receiver to the To: header of a message. // It will return an error if the limit of recipients have been exceeded for this message func (m *Message) AddRecipient(recipient string) error { return m.AddRecipientAndVariables(recipient, nil) } // AddRecipientAndVariables appends a receiver to the To: header of a message, // and as well attaches a set of variables relevant for this recipient. // It will return an error if the limit of recipients have been exceeded for this message func (m *Message) AddRecipientAndVariables(r string, vars map[string]interface{}) error { if m.RecipientCount() >= MaxNumberOfRecipients { return fmt.Errorf("recipient limit exceeded (max %d)", MaxNumberOfRecipients) } m.to = append(m.to, r) if vars != nil { if m.recipientVariables == nil { m.recipientVariables = make(map[string]map[string]interface{}) } m.recipientVariables[r] = vars } return nil } // RecipientCount returns the total number of recipients for the message. // This includes To:, Cc:, and Bcc: fields. // // NOTE: At present, this method is reliable only for non-MIME messages, as the // Bcc: and Cc: fields are easily accessible. // For MIME messages, only the To: field is considered. // A fix for this issue is planned for a future release. // For now, MIME messages are always assumed to have 10 recipients between Cc: and Bcc: fields. // If your MIME messages have more than 10 non-To: field recipients, // you may find that some recipients will not receive your e-mail. // It's perfectly OK, of course, for a MIME message to not have any Cc: or Bcc: recipients. func (m *Message) RecipientCount() int { return len(m.to) + m.specific.recipientCount() } func (pm *plainMessage) recipientCount() int { return len(pm.bcc) + len(pm.cc) } func (mm *mimeMessage) recipientCount() int { return 10 } func (m *Message) send(ctx context.Context) (string, string, error) { return m.mg.Send(ctx, m) } // SetReplyTo sets the receiver who should receive replies func (m *Message) SetReplyTo(recipient string) { m.AddHeader("Reply-To", recipient) } // AddCC appends a receiver to the carbon-copy header of a message. func (m *Message) AddCC(recipient string) { m.specific.addCC(recipient) } func (pm *plainMessage) addCC(r string) { pm.cc = append(pm.cc, r) } func (mm *mimeMessage) addCC(_ string) {} // AddBCC appends a receiver to the blind-carbon-copy header of a message. func (m *Message) AddBCC(recipient string) { m.specific.addBCC(recipient) } func (pm *plainMessage) addBCC(r string) { pm.bcc = append(pm.bcc, r) } func (mm *mimeMessage) addBCC(_ string) {} // SetHtml is a helper. If you're sending a message that isn't already MIME encoded, SetHtml() will arrange to bundle // an HTML representation of your message in addition to your plain-text body. func (m *Message) SetHtml(html string) { m.specific.setHtml(html) } func (pm *plainMessage) setHtml(h string) { pm.html = h } func (mm *mimeMessage) setHtml(_ string) {} // AddTag attaches tags to the message. Tags are useful for metrics gathering and event tracking purposes. // Refer to the Mailgun documentation for further details. func (m *Message) AddTag(tag ...string) error { if len(m.tags) >= MaxNumberOfTags { return fmt.Errorf("cannot add any new tags. Message tag limit (%d) reached", MaxNumberOfTags) } m.tags = append(m.tags, tag...) return nil } // SetTemplate sets the name of a template stored via the template API. // See https://documentation.mailgun.com/en/latest/user_manual.html#templating func (m *Message) SetTemplate(t string) { m.specific.setTemplate(t) } func (pm *plainMessage) setTemplate(t string) { pm.template = t } func (mm *mimeMessage) setTemplate(t string) {} // AddCampaign is no longer supported and is deprecated for new software. func (m *Message) AddCampaign(campaign string) { m.campaigns = append(m.campaigns, campaign) } // SetDKIM arranges to send the o:dkim header with the message, and sets its value accordingly. // Refer to the Mailgun documentation for more information. func (m *Message) SetDKIM(dkim bool) { m.dkim = dkim m.dkimSet = true } // EnableNativeSend allows the return path to match the address in the Message.Headers.From: // field when sending from Mailgun rather than the usual bounce+ address in the return path. func (m *Message) EnableNativeSend() { m.nativeSend = true } // EnableTestMode allows submittal of a message, such that it will be discarded by Mailgun. // This facilitates testing client-side software without actually consuming e-mail resources. func (m *Message) EnableTestMode() { m.testMode = true } // SetDeliveryTime schedules the message for transmission at the indicated time. // Pass nil to remove any installed schedule. // Refer to the Mailgun documentation for more information. func (m *Message) SetDeliveryTime(dt time.Time) { m.deliveryTime = dt } // SetTracking sets the o:tracking message parameter to adjust, on a message-by-message basis, // whether or not Mailgun will rewrite URLs to facilitate event tracking. // Events tracked includes opens, clicks, unsubscribes, etc. // Note: simply calling this method ensures that the o:tracking header is passed in with the message. // Its yes/no setting is determined by the call's parameter. // Note that this header is not passed on to the final recipient(s). // Refer to the Mailgun documentation for more information. func (m *Message) SetTracking(tracking bool) { m.tracking = tracking m.trackingSet = true } // SetTrackingClicks information is found in the Mailgun documentation. func (m *Message) SetTrackingClicks(trackingClicks bool) { m.trackingClicks = trackingClicks m.trackingClicksSet = true } // SetRequireTLS information is found in the Mailgun documentation. func (m *Message) SetRequireTLS(b bool) { m.requireTLS = b } // SetSkipVerification information is found in the Mailgun documentation. func (m *Message) SetSkipVerification(b bool) { m.skipVerification = b } //SetTrackingOpens information is found in the Mailgun documentation. func (m *Message) SetTrackingOpens(trackingOpens bool) { m.trackingOpens = trackingOpens m.trackingOpensSet = true } // AddHeader allows you to send custom MIME headers with the message. func (m *Message) AddHeader(header, value string) { if m.headers == nil { m.headers = make(map[string]string) } m.headers[header] = value } // AddVariable lets you associate a set of variables with messages you send, // which Mailgun can use to, in essence, complete form-mail. // Refer to the Mailgun documentation for more information. func (m *Message) AddVariable(variable string, value interface{}) error { if m.variables == nil { m.variables = make(map[string]string) } j, err := json.Marshal(value) if err != nil { return err } encoded := string(j) v, err := strconv.Unquote(encoded) if err != nil { v = encoded } m.variables[variable] = v return nil } // AddTemplateVariable adds a template variable to the map of template variables, replacing the variable if it is already there. // This is used for server-side message templates and can nest arbitrary values. At send time, the resulting map will be converted into // a JSON string and sent as a header in the X-Mailgun-Variables header. func (m *Message) AddTemplateVariable(variable string, value interface{}) error { if m.templateVariables == nil { m.templateVariables = make(map[string]interface{}) } m.templateVariables[variable] = value return nil } // AddDomain allows you to use a separate domain for the type of messages you are sending. func (m *Message) AddDomain(domain string) { m.domain = domain } // GetHeaders retrieves the http headers associated with this message func (m *Message) GetHeaders() map[string]string { return m.headers } // ErrInvalidMessage is returned by `Send()` when the `mailgun.Message` struct is incomplete var ErrInvalidMessage = errors.New("message not valid") // Send attempts to queue a message (see Message, NewMessage, and its methods) for delivery. // It returns the Mailgun server response, which consists of two components: // a human-readable status message, and a message ID. The status and message ID are set only // if no error occurred. func (mg *MailgunImpl) Send(ctx context.Context, message *Message) (mes string, id string, err error) { if mg.domain == "" { err = errors.New("you must provide a valid domain before calling Send()") return } if mg.apiKey == "" { err = errors.New("you must provide a valid api-key before calling Send()") return } if !isValid(message) { err = ErrInvalidMessage return } payload := newFormDataPayload() message.specific.addValues(payload) for _, to := range message.to { payload.addValue("to", to) } for _, tag := range message.tags { payload.addValue("o:tag", tag) } for _, campaign := range message.campaigns { payload.addValue("o:campaign", campaign) } if message.dkimSet { payload.addValue("o:dkim", yesNo(message.dkim)) } if !message.deliveryTime.IsZero() { payload.addValue("o:deliverytime", formatMailgunTime(message.deliveryTime)) } if message.nativeSend { payload.addValue("o:native-send", "yes") } if message.testMode { payload.addValue("o:testmode", "yes") } if message.trackingSet { payload.addValue("o:tracking", yesNo(message.tracking)) } if message.trackingClicksSet { payload.addValue("o:tracking-clicks", yesNo(message.trackingClicks)) } if message.trackingOpensSet { payload.addValue("o:tracking-opens", yesNo(message.trackingOpens)) } if message.requireTLS { payload.addValue("o:require-tls", trueFalse(message.requireTLS)) } if message.skipVerification { payload.addValue("o:skip-verification", trueFalse(message.skipVerification)) } if message.headers != nil { for header, value := range message.headers { payload.addValue("h:"+header, value) } } if message.variables != nil { for variable, value := range message.variables { payload.addValue("v:"+variable, value) } } if message.templateVariables != nil { variableString, err := json.Marshal(message.templateVariables) if err == nil { // the map was marshalled as json so add it payload.addValue("h:X-Mailgun-Variables", string(variableString)) } } if message.recipientVariables != nil { j, err := json.Marshal(message.recipientVariables) if err != nil { return "", "", err } payload.addValue("recipient-variables", string(j)) } if message.attachments != nil { for _, attachment := range message.attachments { payload.addFile("attachment", attachment) } } if message.readerAttachments != nil { for _, readerAttachment := range message.readerAttachments { payload.addReadCloser("attachment", readerAttachment.Filename, readerAttachment.ReadCloser) } } if message.bufferAttachments != nil { for _, bufferAttachment := range message.bufferAttachments { payload.addBuffer("attachment", bufferAttachment.Filename, bufferAttachment.Buffer) } } if message.inlines != nil { for _, inline := range message.inlines { payload.addFile("inline", inline) } } if message.readerInlines != nil { for _, readerAttachment := range message.readerInlines { payload.addReadCloser("inline", readerAttachment.Filename, readerAttachment.ReadCloser) } } if message.domain == "" { message.domain = mg.Domain() } r := newHTTPRequest(generateApiUrlWithDomain(mg, message.specific.endpoint(), message.domain)) r.setClient(mg.Client()) r.setBasicAuth(basicAuthUser, mg.APIKey()) var response sendMessageResponse err = postResponseFromJSON(ctx, r, payload, &response) if err == nil { mes = response.Message id = response.Id } return } func (pm *plainMessage) addValues(p *formDataPayload) { p.addValue("from", pm.from) p.addValue("subject", pm.subject) p.addValue("text", pm.text) for _, cc := range pm.cc { p.addValue("cc", cc) } for _, bcc := range pm.bcc { p.addValue("bcc", bcc) } if pm.html != "" { p.addValue("html", pm.html) } if pm.template != "" { p.addValue("template", pm.template) } } func (mm *mimeMessage) addValues(p *formDataPayload) { p.addReadCloser("message", "message.mime", mm.body) } func (pm *plainMessage) endpoint() string { return messagesEndpoint } func (mm *mimeMessage) endpoint() string { return mimeMessagesEndpoint } // yesNo translates a true/false boolean value into a yes/no setting suitable for the Mailgun API. func yesNo(b bool) string { if b { return "yes" } return "no" } func trueFalse(b bool) string { if b { return "true" } return "false" } // isValid returns true if, and only if, // a Message instance is sufficiently initialized to send via the Mailgun interface. func isValid(m *Message) bool { if m == nil { return false } if !m.specific.isValid() { return false } if m.RecipientCount() == 0 { return false } if !validateStringList(m.tags, false) { return false } if !validateStringList(m.campaigns, false) || len(m.campaigns) > 3 { return false } return true } func (pm *plainMessage) isValid() bool { if pm.from == "" { return false } if !validateStringList(pm.cc, false) { return false } if !validateStringList(pm.bcc, false) { return false } if pm.template != "" { // pm.text or pm.html not needed if template is supplied return true } if pm.text == "" && pm.html == "" { return false } return true } func (mm *mimeMessage) isValid() bool { return mm.body != nil } // validateStringList returns true if, and only if, // a slice of strings exists AND all of its elements exist, // OR if the slice doesn't exist AND it's not required to exist. // The requireOne parameter indicates whether the list is required to exist. func validateStringList(list []string, requireOne bool) bool { hasOne := false if list == nil { return !requireOne } else { for _, a := range list { if a == "" { return false } else { hasOne = hasOne || true } } } return hasOne } // GetStoredMessage retrieves information about a received e-mail message. // This provides visibility into, e.g., replies to a message sent to a mailing list. func (mg *MailgunImpl) GetStoredMessage(ctx context.Context, url string) (StoredMessage, error) { r := newHTTPRequest(url) r.setClient(mg.Client()) r.setBasicAuth(basicAuthUser, mg.APIKey()) var response StoredMessage err := getResponseFromJSON(ctx, r, &response) return response, err } // Given a storage id resend the stored message to the specified recipients func (mg *MailgunImpl) ReSend(ctx context.Context, url string, recipients ...string) (string, string, error) { r := newHTTPRequest(url) r.setClient(mg.Client()) r.setBasicAuth(basicAuthUser, mg.APIKey()) payload := newFormDataPayload() if len(recipients) == 0 { return "", "", errors.New("must provide at least one recipient") } for _, to := range recipients { payload.addValue("to", to) } var resp sendMessageResponse err := postResponseFromJSON(ctx, r, payload, &resp) if err != nil { return "", "", err } return resp.Message, resp.Id, nil } // GetStoredMessageRaw retrieves the raw MIME body of a received e-mail message. // Compared to GetStoredMessage, it gives access to the unparsed MIME body, and // thus delegates to the caller the required parsing. func (mg *MailgunImpl) GetStoredMessageRaw(ctx context.Context, url string) (StoredMessageRaw, error) { r := newHTTPRequest(url) r.setClient(mg.Client()) r.setBasicAuth(basicAuthUser, mg.APIKey()) r.addHeader("Accept", "message/rfc2822") var response StoredMessageRaw err := getResponseFromJSON(ctx, r, &response) return response, err } // Deprecated: Use GetStoreMessage() instead func (mg *MailgunImpl) GetStoredMessageForURL(ctx context.Context, url string) (StoredMessage, error) { return mg.GetStoredMessage(ctx, url) } // Deprecated: Use GetStoreMessageRaw() instead func (mg *MailgunImpl) GetStoredMessageRawForURL(ctx context.Context, url string) (StoredMessageRaw, error) { return mg.GetStoredMessageRaw(ctx, url) } // GetStoredAttachment retrieves the raw MIME body of a received e-mail message attachment. func (mg *MailgunImpl) GetStoredAttachment(ctx context.Context, url string) ([]byte, error) { r := newHTTPRequest(url) r.setClient(mg.Client()) r.setBasicAuth(basicAuthUser, mg.APIKey()) r.addHeader("Accept", "message/rfc2822") response, err := makeGetRequest(ctx, r) return response.Data, err }