Files
phishingclub/backend/service/apiSender.go
Ronni Skansing 403c6b19a2 added ContentRaw variable to Api Sender content
Signed-off-by: Ronni Skansing <rskansing@gmail.com>
2025-12-18 17:49:56 +01:00

926 lines
27 KiB
Go

package service
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"text/template"
"time"
"github.com/go-errors/errors"
"github.com/google/uuid"
"github.com/oapi-codegen/nullable"
"github.com/phishingclub/phishingclub/data"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/validate"
"github.com/phishingclub/phishingclub/vo"
"go.uber.org/zap"
"gorm.io/gorm"
)
// APISender is a service for API sender
type APISender struct {
Common
TemplateService *Template
CampaignTemplateService *CampaignTemplate
APISenderRepository *repository.APISender
OAuthProviderService *OAuthProvider
}
// APISenderTestResponse is a response for testing API sender
type APISenderTestResponse struct {
APISender *model.APISender `json:"apiSender"`
Request map[string]interface{} `json:"request"`
Response map[string]interface{} `json:"response"`
}
// Create creates a new API sender
func (a *APISender) Create(
ctx context.Context,
session *model.Session,
apiSender *model.APISender,
) (*uuid.UUID, error) {
ae := NewAuditEvent("ApiSender.Create", session)
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
a.LogAuthError(err)
return nil, errs.Wrap(err)
}
if !isAuthorized {
a.AuditLogNotAuthorized(ae)
return nil, errs.ErrAuthorizationFailed
}
// validate
if err := apiSender.Validate(); err != nil {
a.Logger.Errorw("failed to validate API sender", "error", err)
return nil, errs.Wrap(err)
}
var companyID *uuid.UUID
if cid, err := apiSender.CompanyID.Get(); err == nil {
companyID = &cid
}
// check uniqueness
name := apiSender.Name.MustGet()
isOK, err := repository.CheckNameIsUnique(
ctx,
a.APISenderRepository.DB,
"api_senders",
name.String(),
companyID,
nil,
)
if err != nil {
a.Logger.Errorw("failed to check API sender uniqueness", "error", err)
return nil, errs.Wrap(err)
}
if !isOK {
a.Logger.Debugw("AP sender name is already used", "name", name.String())
return nil, validate.WrapErrorWithField(errors.New("is not unique"), "name")
}
// Insert the entity
id, err := a.APISenderRepository.Insert(ctx, apiSender)
if err != nil {
a.Logger.Errorw("failed to insert API sender", "error", err)
return nil, errs.Wrap(err)
}
ae.Details["id"] = id.String()
a.AuditLogAuthorized(ae)
return id, nil
}
// GetAll gets all API senders with pagination
func (a *APISender) GetAll(
ctx context.Context,
session *model.Session,
companyID *uuid.UUID,
option repository.APISenderOption,
) (*model.Result[model.APISender], error) {
ae := NewAuditEvent("ApiSender", session)
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
a.LogAuthError(err)
return nil, errs.Wrap(err)
}
if !isAuthorized {
a.AuditLogNotAuthorized(ae)
return nil, errs.ErrAuthorizationFailed
}
// get all API senders
result, err := a.APISenderRepository.GetAll(
ctx,
companyID,
&option,
)
if err != nil {
a.Logger.Errorw("failed to get all API senders", "error", err)
return nil, errs.Wrap(err)
}
// no audit log of reading
return result, nil
}
// GetAllOverview gets all API senders with limited data
func (a *APISender) GetAllOverview(
ctx context.Context,
session *model.Session,
companyID *uuid.UUID,
option repository.APISenderOption,
) (*model.Result[model.APISender], error) {
ae := NewAuditEvent("ApiSender.GetAllOverview", session)
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
a.LogAuthError(err)
return nil, errs.Wrap(err)
}
if !isAuthorized {
a.AuditLogNotAuthorized(ae)
return nil, errs.ErrAuthorizationFailed
}
// get all API senders
result, err := a.APISenderRepository.GetAllOverview(
ctx,
companyID,
&option,
)
if err != nil {
a.Logger.Errorw("failed to get all API senders", "error", err)
return nil, errs.Wrap(err)
}
// no audit log of reading
return result, nil
}
// GetByID gets a API sender by ID
func (a *APISender) GetByID(
ctx context.Context,
session *model.Session,
id *uuid.UUID,
option *repository.APISenderOption,
) (*model.APISender, error) {
ae := NewAuditEvent("ApiSender.GetByID", session)
ae.Details["id"] = id.String()
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
a.LogAuthError(err)
return nil, errs.Wrap(err)
}
if !isAuthorized {
a.AuditLogNotAuthorized(ae)
return nil, errs.ErrAuthorizationFailed
}
// get API sender by ID
ent, err := a.APISenderRepository.GetByID(
ctx,
id,
option,
)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errs.Wrap(err)
}
if err != nil {
a.Logger.Errorw("failed to get API sender by ID", "error", err)
return nil, errs.Wrap(err)
}
// no audit log for reading
return ent, nil
}
// GetByCompanyID gets a API senders by company ID
func (a *APISender) GetByCompanyID(
ctx context.Context,
session *model.Session,
id *uuid.UUID,
option *repository.APISenderOption,
) (*model.Result[model.APISender], error) {
ae := NewAuditEvent("ApiSender.GetByCompanyID", session)
ae.Details["companyID"] = id.String()
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
a.LogAuthError(err)
return nil, errs.Wrap(err)
}
if !isAuthorized {
a.AuditLogNotAuthorized(ae)
return nil, errs.ErrAuthorizationFailed
}
// get API sender by ID
result, err := a.APISenderRepository.GetAllByCompanyID(
ctx,
id,
option,
)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errs.Wrap(err)
}
if err != nil {
a.Logger.Error("failed to get API senders by company ID", zap.Error(err))
return nil, errs.Wrap(err)
}
// no audit log of reading
return result, nil
}
// Update updates a API sender
func (a *APISender) UpdateByID(
ctx context.Context,
session *model.Session,
id *uuid.UUID,
incoming *model.APISender,
) error {
ae := NewAuditEvent("ApiSender.UpdateByID", session)
ae.Details["id"] = id.String()
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
a.LogAuthError(err)
return err
}
if !isAuthorized {
a.AuditLogNotAuthorized(ae)
return errs.ErrAuthorizationFailed
}
var companyID *uuid.UUID
if incoming.CompanyID.IsSpecified() && !incoming.CompanyID.IsNull() {
cid := incoming.CompanyID.MustGet()
companyID = &cid
}
if incoming.Name.IsSpecified() && !incoming.Name.IsNull() {
// check uniqueness
name := incoming.Name.MustGet()
isOK, err := repository.CheckNameIsUnique(
ctx,
a.APISenderRepository.DB,
"api_senders",
name.String(),
companyID,
id,
)
if err != nil {
a.Logger.Errorw("failed to check API sender uniqueness", "error", err)
return err
}
if !isOK {
a.Logger.Debugw("AP sender name is not unique", "name", name.String())
return validate.WrapErrorWithField(errors.New("is not unique"), "name")
}
}
// update the api sender - if a field is present not not null update it
current, err := a.APISenderRepository.GetByID(ctx, id, &repository.APISenderOption{})
if err != nil {
a.Logger.Errorw("failed to get API sender by ID", "error", err)
return err
}
if v, err := incoming.Name.Get(); err == nil {
current.Name.Set(v)
}
if incoming.CompanyID.IsSpecified() {
if v, err := incoming.CompanyID.Get(); err == nil {
current.CompanyID.Set(v)
} else {
current.CompanyID.SetNull()
}
}
if v, err := incoming.APIKey.Get(); err == nil {
current.APIKey.Set(v)
}
if v, err := incoming.CustomField1.Get(); err == nil {
current.CustomField1.Set(v)
}
if v, err := incoming.CustomField2.Get(); err == nil {
current.CustomField2.Set(v)
}
if v, err := incoming.CustomField3.Get(); err == nil {
current.CustomField3.Set(v)
}
if v, err := incoming.CustomField4.Get(); err == nil {
current.CustomField4.Set(v)
}
if v, err := incoming.RequestMethod.Get(); err == nil {
current.RequestMethod.Set(v)
}
if v, err := incoming.RequestURL.Get(); err == nil {
current.RequestURL.Set(v)
}
if v, err := incoming.RequestHeaders.Get(); err == nil {
current.RequestHeaders.Set(v)
}
if v, err := incoming.RequestBody.Get(); err == nil {
current.RequestBody.Set(v)
}
if incoming.ExpectedResponseStatusCode.IsSpecified() {
if v, err := incoming.ExpectedResponseStatusCode.Get(); err == nil {
current.ExpectedResponseStatusCode.Set(v)
} else {
current.ExpectedResponseStatusCode.SetNull()
}
}
if v, err := incoming.ExpectedResponseHeaders.Get(); err == nil {
current.ExpectedResponseHeaders.Set(v)
}
if v, err := incoming.ExpectedResponseBody.Get(); err == nil {
current.ExpectedResponseBody.Set(v)
}
if incoming.OAuthProviderID.IsSpecified() {
if v, err := incoming.OAuthProviderID.Get(); err == nil {
current.OAuthProviderID.Set(v)
} else {
current.OAuthProviderID.SetNull()
}
}
if err := current.Validate(); err != nil {
a.Logger.Errorw("failed to validate API sender", "error", err)
return err
}
err = a.APISenderRepository.UpdateByID(ctx, id, current)
if err != nil {
a.Logger.Errorw("failed to update API sender", "error", err)
return err
}
a.AuditLogAuthorized(ae)
return nil
}
// DeleteByID deletes a API sender by ID
func (a *APISender) DeleteByID(
ctx context.Context,
session *model.Session,
id *uuid.UUID,
) error {
ae := NewAuditEvent("ApiSender.DeleteByID", session)
ae.Details["id"] = id.String()
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
a.LogAuthError(err)
return err
}
if !isAuthorized {
a.AuditLogNotAuthorized(ae)
return errs.ErrAuthorizationFailed
}
// delete the relation from the campaign templates
err = a.CampaignTemplateService.removeAPISenderIDBySenderID(
ctx,
session,
id,
)
if err != nil {
a.Logger.Errorw("failed to remove API sender relation from campaign templates",
"error", err,
)
return err
}
// delete the entity
err = a.APISenderRepository.DeleteByID(ctx, id)
if err != nil {
a.Logger.Errorw("failed to delete API sender", "error", err)
return err
}
a.AuditLogAuthorized(ae)
return nil
}
// SendTest sends a test request to the API sender
// and returns the response
func (a *APISender) SendTest(
ctx context.Context,
session *model.Session,
id *uuid.UUID,
) (*APISenderTestResponse, error) {
ae := NewAuditEvent("ApiSender.SendTest", session)
ae.Details["id"] = id.String()
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
a.LogAuthError(err)
return nil, errs.Wrap(err)
}
if !isAuthorized {
a.CampaignTemplateService.AuditLogNotAuthorized(ae)
return nil, errs.ErrAuthorizationFailed
}
a.Logger.Debugw("sending test request to API sender", "id", id.String())
// get the API sender with oauth provider
apiSender, err := a.APISenderRepository.GetByID(ctx, id, &repository.APISenderOption{
WithOAuthProvider: true,
})
if err != nil {
a.Logger.Errorw("failed to get API sender by ID", "error", err)
return nil, errs.Wrap(err)
}
// get oauth access token if oauth provider is configured on the api sender
var oauthAccessToken string
oauthProviderID, err := apiSender.OAuthProviderID.Get()
if err == nil && a.OAuthProviderService != nil {
// oauth provider is configured for this api sender
token, tokenErr := a.OAuthProviderService.GetValidAccessToken(ctx, oauthProviderID)
if tokenErr != nil {
a.Logger.Errorw("failed to get oauth access token for test", "error", tokenErr, "oauthProviderID", oauthProviderID)
return nil, errs.Wrap(tokenErr)
}
oauthAccessToken = token
a.Logger.Debugw("got oauth access token for api test request", "oauthProviderID", oauthProviderID)
}
emailRaw := "bob@enterprise.test"
email := *vo.NewEmailMust(emailRaw)
cid := nullable.NewNullableWithValue(uuid.New())
testEmail := &model.Email{
Name: nullable.NewNullableWithValue(
*vo.NewString64Must("Test Email"),
),
MailEnvelopeFrom: nullable.NewNullableWithValue(
*vo.NewMailEnvelopeFromMust(emailRaw),
),
MailHeaderFrom: nullable.NewNullableWithValue(
*vo.NewEmailMust(
fmt.Sprintf("Bob <%s>", emailRaw),
),
),
MailHeaderSubject: nullable.NewNullableWithValue(
*vo.NewOptionalString255Must("Test Email Subject"),
),
Content: nullable.NewNullableWithValue(
*vo.NewOptionalString1MBMust("Hi {{.FirstName}},\n\nThis is a test email.\n\nBest,\nBob"),
),
AddTrackingPixel: nullable.NewNullableWithValue(false),
}
testCampaignRecipient := &model.CampaignRecipient{
ID: cid,
Recipient: &model.Recipient{
ID: cid,
Email: nullable.NewNullableWithValue(
email,
),
Phone: nullable.NewNullableWithValue(
*vo.NewOptionalString127Must("+1234567890"),
),
ExtraIdentifier: nullable.NewNullableWithValue(
*vo.NewOptionalString127Must("extra-test-identifier"),
),
FirstName: nullable.NewNullableWithValue(
*vo.NewOptionalString127Must("Bob"),
),
LastName: nullable.NewNullableWithValue(
*vo.NewOptionalString127Must("Test"),
),
Position: nullable.NewNullableWithValue(
*vo.NewOptionalString127Must("Lead API Tester"),
),
Department: nullable.NewNullableWithValue(
*vo.NewOptionalString127Must("Research and Development"),
),
City: nullable.NewNullableWithValue(
*vo.NewOptionalString127Must("Odin"),
),
Country: nullable.NewNullableWithValue(
*vo.NewOptionalString127Must("Denmark"),
),
Misc: nullable.NewNullableWithValue(
*vo.NewOptionalString127Must("This is a test recipient"),
),
Company: &model.Company{
Name: nullable.NewNullableWithValue(
*vo.NewString64Must("Ravn Enterprise."),
),
},
},
}
url, headers, body, err := a.buildRequestWithCustomURL(
ctx,
apiSender,
"api-sender-test.test",
"id",
"foo/bar",
testCampaignRecipient,
testEmail,
"",
oauthAccessToken,
nil, // no company context for test
)
if err != nil {
a.Logger.Errorw("failed to build test request", "error", err)
return nil, errs.Wrap(err)
}
requestBody := body.String()
res, resBodyClose, err := a.sendRequest(
ctx,
apiSender,
headers,
url,
body,
)
if err != nil {
a.Logger.Errorw("failed to send test request", "error", err)
return nil, errs.Wrap(err)
}
defer resBodyClose()
responseBody, err := io.ReadAll(res.Body)
if err != nil {
a.Logger.Errorw("failed to read response body", "error", err)
return nil, errs.Wrap(err)
}
resData := map[string]any{
"code": res.StatusCode,
"status": res.Status,
"headers": res.Header,
"body": string(responseBody),
}
data := &APISenderTestResponse{
APISender: apiSender,
Request: map[string]any{
"url": url.String(),
"headers": headers,
"body": requestBody,
},
Response: resData,
}
a.AuditLogAuthorized(ae)
return data, nil
}
// Send is a service method that builds and sends a API Sender request
// it does not use auth and must not be used without consideration directly by a controller
func (a *APISender) Send(
ctx context.Context,
session *model.Session,
cTemplate *model.CampaignTemplate,
campaignRecipient *model.CampaignRecipient,
domain *model.Domain,
mailTmpl *template.Template,
email *model.Email,
companyID *uuid.UUID,
) error {
return a.SendWithCustomURL(ctx, session, cTemplate, campaignRecipient, domain, mailTmpl, email, "", companyID)
}
// SendWithCustomURL sends an API request with optional custom campaign URL
func (a *APISender) SendWithCustomURL(
ctx context.Context,
session *model.Session,
cTemplate *model.CampaignTemplate,
campaignRecipient *model.CampaignRecipient,
domain *model.Domain,
mailTmpl *template.Template,
email *model.Email,
customCampaignURL string,
companyID *uuid.UUID,
) error {
// get sender details
apiSenderID, err := cTemplate.APISenderID.Get()
if err != nil {
a.Logger.Infow(
"failed to get API Sender relation from template. Template is incomplete",
"error", err,
)
return err
}
apiSender, err := a.GetByID(
ctx,
session,
&apiSenderID,
&repository.APISenderOption{
WithOAuthProvider: true,
},
)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("api sender did not load: %s", err)
}
if apiSender == nil {
return errors.New("api sender did not load")
}
// get oauth access token if oauth provider is configured on the api sender
var oauthAccessToken string
oauthProviderID, err := apiSender.OAuthProviderID.Get()
if err == nil && a.OAuthProviderService != nil {
// oauth provider is configured for this api sender
token, err := a.OAuthProviderService.GetValidAccessToken(ctx, oauthProviderID)
if err != nil {
a.Logger.Errorw("failed to get oauth access token", "error", err, "oauthProviderID", oauthProviderID)
return fmt.Errorf("failed to get oauth access token: %w", err)
}
oauthAccessToken = token
a.Logger.Debugw("got oauth access token for api request", "oauthProviderID", oauthProviderID)
}
domainName := domain.Name.MustGet()
urlIdentifier := cTemplate.URLIdentifier
if urlIdentifier == nil {
return errors.New("url identifier MUST be loaded in campaign template")
}
urlPath := cTemplate.URLPath.MustGet().String()
url, headers, body, err := a.buildRequestWithCustomURL(
ctx,
apiSender,
domainName.String(),
urlIdentifier.Name.MustGet(),
urlPath,
campaignRecipient,
email,
customCampaignURL,
oauthAccessToken,
companyID,
)
if err != nil {
a.Logger.Errorw("failed to build api sender request", "error", err)
return err
}
resp, respBodyClose, err := a.sendRequest(
ctx,
apiSender,
headers,
url,
body,
)
if err != nil {
a.Logger.Errorw("failed to send api sender request", "error", err)
return err
}
defer respBodyClose()
// read response body once for reuse in error messages
resBody, err := io.ReadAll(resp.Body)
if err != nil {
a.Logger.Errorw("failed to read response body", "error", err)
return err
}
// check if response matches expectations
nullableExpectedResponseCode := apiSender.ExpectedResponseStatusCode
if nullableExpectedResponseCode.IsSpecified() && !nullableExpectedResponseCode.IsNull() {
expectedResponseStatusCode := nullableExpectedResponseCode.MustGet()
if resp.StatusCode != expectedResponseStatusCode {
a.Logger.Debugw("api sender got unexpected response status code",
"statusCode", resp.StatusCode,
"responseBody", string(resBody),
)
return fmt.Errorf("unexpected response status code: %d, body: %s", resp.StatusCode, string(resBody))
}
}
// check for expected headers
nullableExpectedHeaders := apiSender.ExpectedResponseHeaders
if nullableExpectedHeaders.IsSpecified() && !nullableExpectedHeaders.IsNull() {
expectedHeaders := nullableExpectedHeaders.MustGet()
for _, expectedHeader := range expectedHeaders.Headers {
header := resp.Header.Get(expectedHeader.Key)
if !strings.Contains(header, expectedHeader.Value) {
a.Logger.Debugw("api sender got unexpected response header",
"expectedKey", expectedHeader.Key,
"expectedValue", expectedHeader.Value,
"header", header,
"responseBody", string(resBody),
)
return fmt.Errorf("unexpected response header: expected '%s' to contain '%s' but has '%s', body: %s", expectedHeader.Key, expectedHeader.Value, header, string(resBody))
}
}
}
nullableExpectedBody := apiSender.ExpectedResponseBody
if nullableExpectedBody.IsSpecified() && !nullableExpectedBody.IsNull() {
expectedBody := nullableExpectedBody.MustGet()
// check for expected body
if !bytes.Contains(resBody, []byte(expectedBody.String())) {
a.Logger.Debugw("api sender got unexpected response body",
"expectedBody", expectedBody,
"body", string(resBody),
)
return fmt.Errorf(
"unexpected response body: expected to contain '%s', got: %s",
expectedBody,
string(resBody),
)
}
}
return nil
}
func (a *APISender) buildHeader(
apiSender *model.APISender,
templateData *map[string]any,
) ([]*model.HTTPHeader, error) {
// setup headers
apiReqHeaders := []*model.HTTPHeader{}
requestHeaders := apiSender.RequestHeaders
if requestHeaders.IsSpecified() && !requestHeaders.IsNull() {
for _, header := range requestHeaders.MustGet().Headers {
keyTemplate := template.New("key")
keyTemplate, err := keyTemplate.Parse(header.Key)
if err != nil {
return nil, fmt.Errorf("failed to parse header key: %s", err)
}
keyTemplate = keyTemplate.Funcs(TemplateFuncs())
var key bytes.Buffer
if err := keyTemplate.Execute(&key, templateData); err != nil {
return nil, errs.Wrap(err)
}
valueTemplate := template.New("value")
valueTemplate, err = valueTemplate.Parse(header.Value)
if err != nil {
return nil, fmt.Errorf("failed to parse header value: %s", err)
}
valueTemplate = valueTemplate.Funcs(TemplateFuncs())
var value bytes.Buffer
if err := valueTemplate.Execute(&value, templateData); err != nil {
return nil, fmt.Errorf("failed to execute value template: %s", err)
}
apiReqHeaders = append(
apiReqHeaders,
&model.HTTPHeader{
Key: key.String(),
Value: value.String(),
},
)
}
}
return apiReqHeaders, nil
}
// sendRequest builds and sends the request to the API
// it returns the response, a function to close the response body, and an error
// the close method MUST be called to avoid leaking resources
func (a *APISender) sendRequest(
ctx context.Context,
apiSender *model.APISender,
apiRequestHeaders []*model.HTTPHeader,
apiRequestURL *apiRequestURL,
apiRequestBody *apiRequestBody,
) (*http.Response, func(), error) {
// prepare request
reqCtx, reqCancel := context.WithTimeout(ctx, 10*time.Second)
// context must stay alive until response body is read
if apiRequestBody == nil {
apiRequestBody = bytes.NewBuffer([]byte{})
}
req, err := http.NewRequestWithContext(
reqCtx,
apiSender.RequestMethod.MustGet().String(),
apiRequestURL.String(),
apiRequestBody,
)
if err != nil {
reqCancel()
return nil, func() {}, errs.Wrap(err)
}
// TODO these headers should be enrished with template variables like {{.FirstName}} or etc
for _, header := range apiRequestHeaders {
req.Header.Set(header.Key, header.Value)
}
// debug logging: output request details
// build headers map for logging
headersMap := make(map[string]string)
for _, header := range apiRequestHeaders {
headersMap[header.Key] = header.Value
}
// log request details
a.Logger.Debugw("sending api request",
"method", apiSender.RequestMethod.MustGet().String(),
"url", apiRequestURL.String(),
"headers", headersMap,
"body", apiRequestBody.String(),
)
// send request
resp, err := http.DefaultClient.Do(req)
if err != nil {
reqCancel()
return nil, func() {}, errs.Wrap(err)
}
// return cleanup function that closes body and cancels context
// context must not be canceled until after response body is read
// otherwise io.ReadAll(resp.Body) can fail with "context canceled"
return resp, func() {
resp.Body.Close()
reqCancel()
}, nil
}
type apiRequestURL = bytes.Buffer
type apiRequestBody = bytes.Buffer
func (a *APISender) buildRequest(
ctx context.Context,
apiSender *model.APISender,
domainName string,
urlKey string,
urlPath string,
campaignRecipient *model.CampaignRecipient,
email *model.Email, // todo is this superfluous? it should be in the campaign recipient?
companyID *uuid.UUID,
) (*apiRequestURL, []*model.HTTPHeader, *apiRequestBody, error) {
return a.buildRequestWithCustomURL(ctx, apiSender, domainName, urlKey, urlPath, campaignRecipient, email, "", "", companyID)
}
// buildRequestWithCustomURL builds an API request with optional custom campaign URL
func (a *APISender) buildRequestWithCustomURL(
ctx context.Context,
apiSender *model.APISender,
domainName string,
urlKey string,
urlPath string,
campaignRecipient *model.CampaignRecipient,
email *model.Email,
customCampaignURL string,
oauthAccessToken string,
companyID *uuid.UUID,
) (*apiRequestURL, []*model.HTTPHeader, *apiRequestBody, error) {
// create template data first so it can be used in headers, url, and body
t := a.TemplateService.CreateMail(
ctx,
domainName,
urlKey,
urlPath,
campaignRecipient,
email,
apiSender,
companyID,
)
// add oauth access token to template data if available
if oauthAccessToken != "" {
(*t)["OAuthAccessToken"] = oauthAccessToken
}
// override campaign URL if custom one is provided
if customCampaignURL != "" {
templateURL := fmt.Sprintf("https://%s%s?%s=%s", domainName, urlPath, urlKey, campaignRecipient.ID.MustGet().String())
if customCampaignURL != templateURL {
(*t)["URL"] = customCampaignURL
}
}
// setup headers
apiReqHeaders, err := a.buildHeader(apiSender, t)
if err != nil {
return nil, nil, nil, fmt.Errorf("failed to build headers: %s", err)
}
// setup URL
requestURL := apiSender.RequestURL.MustGet()
urlTemplate := template.New("url")
urlTemplate = urlTemplate.Funcs(TemplateFuncs())
urlTemplate, err = urlTemplate.Parse(requestURL.String())
if err != nil {
return nil, nil, nil, fmt.Errorf("failed to parse url: %s", err)
}
var apiURL bytes.Buffer
if err := urlTemplate.Execute(&apiURL, t); err != nil {
return nil, nil, nil, fmt.Errorf("failed to execute url template: %s", err)
}
// setup body
// first parse and execute the mail content
mailContentTemplate := template.New("mailContent")
mailContentTemplate = mailContentTemplate.Funcs(TemplateFuncs())
content, err := email.Content.Get()
if err != nil {
return nil, nil, nil, fmt.Errorf("failed to get email content: %s", err)
}
mailTemplate, err := mailContentTemplate.Parse(content.String())
if err != nil {
return nil, nil, nil, fmt.Errorf("failed to parse body: %s", err)
}
var mailContent bytes.Buffer
if err := mailTemplate.Execute(&mailContent, t); err != nil {
return nil, nil, nil, fmt.Errorf("failed to execute mail template: %s", err)
}
// store raw content (no escaping for output in json directly)
(*t)["ContentRaw"] = mailContent.String()
// Properly encode for JSON
var buf bytes.Buffer
encoder := json.NewEncoder(&buf)
encoder.SetEscapeHTML(false)
if err := encoder.Encode(mailContent.String()); err != nil {
return nil, nil, nil, fmt.Errorf("failed to marshal mail content: %s", err)
}
// Remove the newline that Encode adds and the surrounding quotes
jsonStr := strings.TrimSpace(buf.String())
(*t)["Content"] = jsonStr[1 : len(jsonStr)-1]
contentTemplate := template.New("content")
contentTemplate = contentTemplate.Funcs(TemplateFuncs())
contentTemplate, err = contentTemplate.Parse(apiSender.RequestBody.MustGet().String())
if err != nil {
return nil, nil, nil, fmt.Errorf("failed to parse body: %s", err)
}
var body bytes.Buffer
if err := contentTemplate.Execute(&body, t); err != nil {
return nil, nil, nil, fmt.Errorf("failed to execute body template: %s", err)
}
return &apiURL, apiReqHeaders, &body, nil
}