diff --git a/README.md b/README.md index 9f075d7..22c7e1c 100644 --- a/README.md +++ b/README.md @@ -378,6 +378,10 @@ You can find the [Mailtrap Java API reference](https://mailtrap.github.io/mailtr - [Email Templates](examples/java/io/mailtrap/examples/emailtemplates/EmailTemplatesExample.java) +### Email Marketing API + +- [Email Campaigns](examples/java/io/mailtrap/examples/emailcampaigns/EmailCampaignsExample.java) + ## Contributing Bug reports and pull requests are welcome on [GitHub](https://github.com/mailtrap/mailtrap-java). This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](CODE_OF_CONDUCT.md). diff --git a/examples/java/io/mailtrap/examples/emailcampaigns/EmailCampaignsExample.java b/examples/java/io/mailtrap/examples/emailcampaigns/EmailCampaignsExample.java new file mode 100644 index 0000000..a017b65 --- /dev/null +++ b/examples/java/io/mailtrap/examples/emailcampaigns/EmailCampaignsExample.java @@ -0,0 +1,102 @@ +package io.mailtrap.examples.emailcampaigns; + +import io.mailtrap.config.MailtrapConfig; +import io.mailtrap.factory.MailtrapClientFactory; +import io.mailtrap.model.DeliveryMode; +import io.mailtrap.model.request.emailcampaigns.CreateEmailCampaign; +import io.mailtrap.model.request.emailcampaigns.ScheduleEmailCampaignRequest; +import io.mailtrap.model.request.emailcampaigns.TemplateAttributes; +import io.mailtrap.model.request.emailcampaigns.UpdateEmailCampaign; +import io.mailtrap.model.response.emailcampaigns.DeliveryOptions; +import io.mailtrap.model.response.emailcampaigns.ReplyTo; + +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.List; + +public class EmailCampaignsExample { + + private static final String TOKEN = ""; + // UUID of a verified sending domain on the account. + private static final String MAILSEND_DOMAIN_ID = ""; + private static final long CONTACT_LIST_ID = 55L; + + public static void main(String[] args) { + final var config = new MailtrapConfig.Builder() + .token(TOKEN) + .build(); + + final var client = MailtrapClientFactory.createMailtrapClient(config); + + // The campaign endpoints are token-scoped: the account is resolved from the API token. + final var campaigns = client.emailCampaignsApi().emailCampaigns(); + + // List campaigns (newest first). `search` filters by name; `token` is the page number. + final var page = campaigns.getEmailCampaigns(50, "Spring", 1); + System.out.println(page); + + // Create a campaign — it starts in the `draft` state. The request body is flat. + final var created = campaigns.createEmailCampaign( + CreateEmailCampaign.builder() + .name("Spring Sale") + .mailsendDomainId(MAILSEND_DOMAIN_ID) + .fromDisplayName("Acme Marketing") + .fromLocalPart("news") + .replyTo(ReplyTo.builder() + .displayName("Acme Support") + .localPart("support") + .domain("acme.com") + .build()) + .templateAttributes(TemplateAttributes.builder() + .subject("Spring is here — 30% off") + .build()) + .contactListIds(List.of(CONTACT_LIST_ID)) + .build()); + System.out.println(created.getData()); + + final var campaignId = created.getData().getId(); + + // Retrieve a single campaign. + final var fetched = campaigns.getEmailCampaign(campaignId); + System.out.println(fetched.getData()); + + // Update is a PATCH — only the provided fields change. The template is edited in place; + // `bodyHtml` is the design and must contain an unsubscribe link. + final var updated = campaigns.updateEmailCampaign(campaignId, + UpdateEmailCampaign.builder() + .name("Spring Sale (updated)") + .templateAttributes(TemplateAttributes.builder() + .subject("New subject") + .bodyHtml("

Hi {{first_name}}!

" + + "

Unsubscribe

") + .mergeTags(List.of("first_name")) + .build()) + .deliveryMode(DeliveryMode.GRADUAL) + .deliveryOptions(DeliveryOptions.builder().emailsPerHour(1000).build()) + .build()); + System.out.println(updated.getData()); + + // Schedule the draft to send later — the time must be in the future, at most 1 month + // ahead; it comes back in currentStateMetadata.scheduledAt. + final var scheduled = campaigns.scheduleEmailCampaign(campaignId, + new ScheduleEmailCampaignRequest(OffsetDateTime.now(ZoneOffset.UTC).plusDays(1))); + System.out.println(scheduled.getData().getCurrentStateMetadata().getScheduledAt()); + + // Cancel the scheduled send — the campaign returns to `draft`. + final var cancelled = campaigns.cancelEmailCampaign(campaignId); + System.out.println(cancelled.getData().getCurrentState()); + + // Or start sending immediately. + final var started = campaigns.startEmailCampaign(campaignId); + System.out.println(started.getData().getCurrentState()); + + // Aggregated performance statistics; narrow the window with start/end dates (YYYY-MM-DD). + final var today = LocalDate.now(ZoneOffset.UTC); + final var stats = campaigns.getEmailCampaignStats(campaignId, today.minusDays(30).toString(), today.toString()); + System.out.println(stats.getData()); + + // Delete returns 204 No Content. + campaigns.deleteEmailCampaign(campaignId); + } +} diff --git a/src/main/java/io/mailtrap/api/emailcampaigns/EmailCampaigns.java b/src/main/java/io/mailtrap/api/emailcampaigns/EmailCampaigns.java new file mode 100644 index 0000000..6f79d5d --- /dev/null +++ b/src/main/java/io/mailtrap/api/emailcampaigns/EmailCampaigns.java @@ -0,0 +1,130 @@ +package io.mailtrap.api.emailcampaigns; + +import io.mailtrap.model.request.emailcampaigns.CreateEmailCampaign; +import io.mailtrap.model.request.emailcampaigns.ScheduleEmailCampaignRequest; +import io.mailtrap.model.request.emailcampaigns.UpdateEmailCampaign; +import io.mailtrap.model.response.emailcampaigns.EmailCampaignListResponse; +import io.mailtrap.model.response.emailcampaigns.EmailCampaignResponse; +import io.mailtrap.model.response.emailcampaigns.EmailCampaignStatsResponse; + +/** + * Email Campaigns API. Manage email marketing campaigns and retrieve their performance + * statistics. + * + *

The account is resolved from the API token, so these endpoints are token-scoped and the + * path is not account-scoped. + */ +public interface EmailCampaigns { + + /** + * List the account's email campaigns, newest first. + * + * @param perPage number of campaigns per page (max 100, default 50); {@code null} to omit + * @param search filter campaigns by name; {@code null} to omit + * @param token page number to retrieve (page-token pagination, default 1); + * {@code null} to omit + * @return a page of campaigns and the pagination metadata + */ + EmailCampaignListResponse getEmailCampaigns(Integer perPage, String search, Integer token); + + /** + * Create a new email campaign in the {@code draft} state. + * + * @param request the campaign attributes ({@code name}, {@code mailsendDomainId}, + * {@code fromLocalPart} and a template {@code subject} are required) + * @return the created email campaign + */ + EmailCampaignResponse createEmailCampaign(CreateEmailCampaign request); + + /** + * Get a single email campaign by ID. + * + * @param emailCampaignId unique email campaign ID + * @return the email campaign + */ + EmailCampaignResponse getEmailCampaign(long emailCampaignId); + + /** + * Update an existing {@code draft} email campaign. Only the provided attributes are + * changed. + * + * @param emailCampaignId unique email campaign ID + * @param request the attributes to update + * @return the updated email campaign + */ + EmailCampaignResponse updateEmailCampaign(long emailCampaignId, UpdateEmailCampaign request); + + /** + * Delete an email campaign. The campaign must not be in a sending state. + * + * @param emailCampaignId unique email campaign ID + */ + void deleteEmailCampaign(long emailCampaignId); + + /** + * Start sending a {@code draft} campaign immediately. + * + * @param emailCampaignId unique email campaign ID + * @return the started email campaign + */ + EmailCampaignResponse startEmailCampaign(long emailCampaignId); + + /** + * Schedule a {@code draft} campaign to start sending at a future time. The scheduled time + * is reported back in {@code currentStateMetadata.scheduledAt}. + * + * @param emailCampaignId unique email campaign ID + * @param request when to start sending the campaign + * @return the scheduled email campaign + */ + EmailCampaignResponse scheduleEmailCampaign(long emailCampaignId, ScheduleEmailCampaignRequest request); + + /** + * Cancel a {@code scheduled} campaign, returning it to the {@code draft} state. + * + * @param emailCampaignId unique email campaign ID + * @return the cancelled email campaign + */ + EmailCampaignResponse cancelEmailCampaign(long emailCampaignId); + + /** + * Terminate a campaign that is currently sending ({@code started}, {@code queued} or + * {@code paused}), aborting the in-flight send. + * + * @param emailCampaignId unique email campaign ID + * @return the terminated email campaign + */ + EmailCampaignResponse terminateEmailCampaign(long emailCampaignId); + + /** + * Reset a {@code scheduled} campaign back to the {@code draft} state. + * + * @param emailCampaignId unique email campaign ID + * @return the reset email campaign + */ + EmailCampaignResponse resetEmailCampaign(long emailCampaignId); + + /** + * Get aggregated performance statistics for an email campaign over the whole period since + * the campaign was last started. If the campaign has never been started, all counts and + * rates are returned as {@code 0}. + * + * @param emailCampaignId unique email campaign ID + * @return aggregated campaign statistics + */ + EmailCampaignStatsResponse getEmailCampaignStats(long emailCampaignId); + + /** + * Get aggregated performance statistics for an email campaign over a narrowed aggregation + * window. + * + * @param emailCampaignId unique email campaign ID + * @param startDate start of the aggregation window (inclusive), in + * {@code YYYY-MM-DD} format; {@code null} to omit + * @param endDate end of the aggregation window (inclusive), in {@code YYYY-MM-DD} + * format; {@code null} to omit + * @return aggregated campaign statistics + */ + EmailCampaignStatsResponse getEmailCampaignStats(long emailCampaignId, String startDate, String endDate); + +} diff --git a/src/main/java/io/mailtrap/api/emailcampaigns/EmailCampaignsImpl.java b/src/main/java/io/mailtrap/api/emailcampaigns/EmailCampaignsImpl.java new file mode 100644 index 0000000..43d122d --- /dev/null +++ b/src/main/java/io/mailtrap/api/emailcampaigns/EmailCampaignsImpl.java @@ -0,0 +1,138 @@ +package io.mailtrap.api.emailcampaigns; + +import io.mailtrap.Constants; +import io.mailtrap.api.apiresource.ApiResource; +import io.mailtrap.config.MailtrapConfig; +import io.mailtrap.http.RequestData; +import io.mailtrap.model.AbstractModel; +import io.mailtrap.model.request.emailcampaigns.CreateEmailCampaign; +import io.mailtrap.model.request.emailcampaigns.ScheduleEmailCampaignRequest; +import io.mailtrap.model.request.emailcampaigns.UpdateEmailCampaign; +import io.mailtrap.model.response.emailcampaigns.EmailCampaignListResponse; +import io.mailtrap.model.response.emailcampaigns.EmailCampaignResponse; +import io.mailtrap.model.response.emailcampaigns.EmailCampaignStatsResponse; + +import java.util.Optional; + +import static io.mailtrap.http.RequestData.entry; + +public class EmailCampaignsImpl extends ApiResource implements EmailCampaigns { + + private static final String BASE_PATH = "/api/email_campaigns"; + + public EmailCampaignsImpl(final MailtrapConfig config) { + super(config); + this.apiHost = Constants.GENERAL_HOST; + } + + @Override + public EmailCampaignListResponse getEmailCampaigns(final Integer perPage, final String search, final Integer token) { + final var queryParams = RequestData.buildQueryParams( + entry("per_page", Optional.ofNullable(perPage)), + entry("search", Optional.ofNullable(search)), + entry("token", Optional.ofNullable(token)) + ); + + return httpClient.get( + apiHost + BASE_PATH, + new RequestData(queryParams), + EmailCampaignListResponse.class + ); + } + + @Override + public EmailCampaignResponse createEmailCampaign(final CreateEmailCampaign request) { + return httpClient.post( + apiHost + BASE_PATH, + request, + new RequestData(), + EmailCampaignResponse.class + ); + } + + @Override + public EmailCampaignResponse getEmailCampaign(final long emailCampaignId) { + return httpClient.get( + String.format(apiHost + BASE_PATH + "/%d", emailCampaignId), + new RequestData(), + EmailCampaignResponse.class + ); + } + + @Override + public EmailCampaignResponse updateEmailCampaign(final long emailCampaignId, final UpdateEmailCampaign request) { + return httpClient.patch( + String.format(apiHost + BASE_PATH + "/%d", emailCampaignId), + request, + new RequestData(), + EmailCampaignResponse.class + ); + } + + @Override + public void deleteEmailCampaign(final long emailCampaignId) { + httpClient.delete( + String.format(apiHost + BASE_PATH + "/%d", emailCampaignId), + new RequestData(), + Void.class + ); + } + + @Override + public EmailCampaignResponse startEmailCampaign(final long emailCampaignId) { + return performLifecycleAction(emailCampaignId, "start"); + } + + @Override + public EmailCampaignResponse scheduleEmailCampaign(final long emailCampaignId, final ScheduleEmailCampaignRequest request) { + return httpClient.post( + String.format(apiHost + BASE_PATH + "/%d/schedule", emailCampaignId), + request, + new RequestData(), + EmailCampaignResponse.class + ); + } + + @Override + public EmailCampaignResponse cancelEmailCampaign(final long emailCampaignId) { + return performLifecycleAction(emailCampaignId, "cancel"); + } + + @Override + public EmailCampaignResponse terminateEmailCampaign(final long emailCampaignId) { + return performLifecycleAction(emailCampaignId, "terminate"); + } + + @Override + public EmailCampaignResponse resetEmailCampaign(final long emailCampaignId) { + return performLifecycleAction(emailCampaignId, "reset"); + } + + @Override + public EmailCampaignStatsResponse getEmailCampaignStats(final long emailCampaignId) { + return getEmailCampaignStats(emailCampaignId, null, null); + } + + @Override + public EmailCampaignStatsResponse getEmailCampaignStats(final long emailCampaignId, final String startDate, final String endDate) { + final var queryParams = RequestData.buildQueryParams( + entry("start_date", Optional.ofNullable(startDate)), + entry("end_date", Optional.ofNullable(endDate)) + ); + + return httpClient.get( + String.format(apiHost + BASE_PATH + "/%d/stats", emailCampaignId), + new RequestData(queryParams), + EmailCampaignStatsResponse.class + ); + } + + private EmailCampaignResponse performLifecycleAction(final long emailCampaignId, final String action) { + return httpClient.post( + String.format(apiHost + BASE_PATH + "/%d/%s", emailCampaignId, action), + (AbstractModel) null, + new RequestData(), + EmailCampaignResponse.class + ); + } +} diff --git a/src/main/java/io/mailtrap/client/MailtrapClient.java b/src/main/java/io/mailtrap/client/MailtrapClient.java index bb9505f..cdec0b9 100644 --- a/src/main/java/io/mailtrap/client/MailtrapClient.java +++ b/src/main/java/io/mailtrap/client/MailtrapClient.java @@ -62,6 +62,12 @@ public class MailtrapClient { @Getter private final MailtrapOrganizationsApi organizationsApi; + /** + * API for Mailtrap.io Email Campaigns functionality + */ + @Getter + private final MailtrapEmailCampaignsApi emailCampaignsApi; + /** * Utility class which holds sending context (which API to use: Email Sending API, Bulk Sending API or * Email Testing API, inbox id for Email Testing API) to make it possible to perform send directly from MailtrapClient diff --git a/src/main/java/io/mailtrap/client/api/MailtrapEmailCampaignsApi.java b/src/main/java/io/mailtrap/client/api/MailtrapEmailCampaignsApi.java new file mode 100644 index 0000000..6108584 --- /dev/null +++ b/src/main/java/io/mailtrap/client/api/MailtrapEmailCampaignsApi.java @@ -0,0 +1,20 @@ +package io.mailtrap.client.api; + +import io.mailtrap.api.emailcampaigns.EmailCampaigns; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.experimental.Accessors; + +/** + * Represents an API for Mailtrap Email Campaigns functionality. + * + *

Email campaigns use a token-scoped, non-account-scoped URL family + * ({@code /api/email_campaigns}), so they are grouped under their own API rather than the + * account-scoped general API. + */ +@Getter +@Accessors(fluent = true) +@RequiredArgsConstructor +public class MailtrapEmailCampaignsApi { + private final EmailCampaigns emailCampaigns; +} diff --git a/src/main/java/io/mailtrap/factory/MailtrapClientFactory.java b/src/main/java/io/mailtrap/factory/MailtrapClientFactory.java index 9edf03a..6120f83 100644 --- a/src/main/java/io/mailtrap/factory/MailtrapClientFactory.java +++ b/src/main/java/io/mailtrap/factory/MailtrapClientFactory.java @@ -13,6 +13,7 @@ import io.mailtrap.api.contactimports.ContactImportsImpl; import io.mailtrap.api.contactlists.ContactListsImpl; import io.mailtrap.api.contacts.ContactsImpl; +import io.mailtrap.api.emailcampaigns.EmailCampaignsImpl; import io.mailtrap.api.emailtemplates.EmailTemplatesImpl; import io.mailtrap.api.inboxes.InboxesImpl; import io.mailtrap.api.messages.MessagesImpl; @@ -73,11 +74,18 @@ public static MailtrapClient createMailtrapClient(final MailtrapConfig config) { final var contactsApi = createContactsApi(config); final var emailTemplatesApi = createEmailTemplatesApi(config); final var organizationsApi = createOrganizationsApi(config); + final var emailCampaignsApi = createEmailCampaignsApi(config); final var sendingContextHolder = configureSendingContext(config); return new MailtrapClient(sendingApi, testingApi, bulkSendingApi, generalApi, contactsApi, emailTemplatesApi, - organizationsApi, sendingContextHolder); + organizationsApi, emailCampaignsApi, sendingContextHolder); + } + + private static MailtrapEmailCampaignsApi createEmailCampaignsApi(final MailtrapConfig config) { + final var emailCampaigns = new EmailCampaignsImpl(config); + + return new MailtrapEmailCampaignsApi(emailCampaigns); } private static MailtrapOrganizationsApi createOrganizationsApi(final MailtrapConfig config) { diff --git a/src/main/java/io/mailtrap/model/CampaignState.java b/src/main/java/io/mailtrap/model/CampaignState.java new file mode 100644 index 0000000..4a9d46f --- /dev/null +++ b/src/main/java/io/mailtrap/model/CampaignState.java @@ -0,0 +1,46 @@ +package io.mailtrap.model; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +/** + * Current state of an email campaign in its lifecycle. + */ +public enum CampaignState { + DRAFT("draft"), + SCHEDULED("scheduled"), + STARTED("started"), + QUEUED("queued"), + PAUSED("paused"), + TERMINATING("terminating"), + UNDER_REVIEW("under_review"), + FINISHED("finished"), + FAILED("failed"), + FAILED_IMMEDIATELY("failed_immediately"); + + private final String value; + + CampaignState(String value) { + this.value = value; + } + + @JsonValue + public String getValue() { + return value; + } + + @Override + public String toString() { + return value; + } + + @JsonCreator + public static CampaignState fromValue(String value) { + for (CampaignState state : CampaignState.values()) { + if (state.value.equalsIgnoreCase(value)) { + return state; + } + } + throw new IllegalArgumentException("Unknown value: " + value); + } +} diff --git a/src/main/java/io/mailtrap/model/DeliveryMode.java b/src/main/java/io/mailtrap/model/DeliveryMode.java new file mode 100644 index 0000000..9ab3e79 --- /dev/null +++ b/src/main/java/io/mailtrap/model/DeliveryMode.java @@ -0,0 +1,39 @@ +package io.mailtrap.model; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +/** + * How an email campaign is delivered. {@code RAPID} sends as fast as possible; {@code GRADUAL} + * throttles sending to {@code delivery_options.emails_per_hour}. + */ +public enum DeliveryMode { + RAPID("rapid"), + GRADUAL("gradual"); + + private final String value; + + DeliveryMode(String value) { + this.value = value; + } + + @JsonValue + public String getValue() { + return value; + } + + @Override + public String toString() { + return value; + } + + @JsonCreator + public static DeliveryMode fromValue(String value) { + for (DeliveryMode mode : DeliveryMode.values()) { + if (mode.value.equalsIgnoreCase(value)) { + return mode; + } + } + throw new IllegalArgumentException("Unknown value: " + value); + } +} diff --git a/src/main/java/io/mailtrap/model/request/emailcampaigns/CreateEmailCampaign.java b/src/main/java/io/mailtrap/model/request/emailcampaigns/CreateEmailCampaign.java new file mode 100644 index 0000000..57937e3 --- /dev/null +++ b/src/main/java/io/mailtrap/model/request/emailcampaigns/CreateEmailCampaign.java @@ -0,0 +1,71 @@ +package io.mailtrap.model.request.emailcampaigns; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.mailtrap.model.AbstractModel; +import io.mailtrap.model.DeliveryMode; +import io.mailtrap.model.response.emailcampaigns.DeliveryOptions; +import io.mailtrap.model.response.emailcampaigns.ReplyTo; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * Attributes used to create an email campaign. {@code name}, {@code mailsendDomainId}, + * {@code fromLocalPart} and a template {@code subject} within {@code templateAttributes} are + * required by the API. The campaign is always created in the {@code draft} state; scheduling + * and starting are separate actions. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class CreateEmailCampaign extends AbstractModel { + + private String name; + + /** + * UUID of the verified sending domain used for the campaign. + */ + @JsonProperty("mailsend_domain_id") + private String mailsendDomainId; + + @JsonProperty("from_display_name") + private String fromDisplayName; + + @JsonProperty("from_local_part") + private String fromLocalPart; + + @JsonProperty("reply_to") + private ReplyTo replyTo; + + @JsonProperty("template_attributes") + private TemplateAttributes templateAttributes; + + @JsonProperty("delivery_mode") + private DeliveryMode deliveryMode; + + /** + * Delivery throttling options. Applies when {@code deliveryMode} is + * {@link DeliveryMode#GRADUAL}. + */ + @JsonProperty("delivery_options") + private DeliveryOptions deliveryOptions; + + /** + * IDs of contact lists to send to. Treated as the full set of included lists. + */ + @JsonProperty("contact_list_ids") + private List contactListIds; + + /** + * IDs of contact segments to send to. Treated as the full set of included segments. + */ + @JsonProperty("contact_segment_ids") + private List contactSegmentIds; + +} diff --git a/src/main/java/io/mailtrap/model/request/emailcampaigns/ScheduleEmailCampaignRequest.java b/src/main/java/io/mailtrap/model/request/emailcampaigns/ScheduleEmailCampaignRequest.java new file mode 100644 index 0000000..b57f930 --- /dev/null +++ b/src/main/java/io/mailtrap/model/request/emailcampaigns/ScheduleEmailCampaignRequest.java @@ -0,0 +1,30 @@ +package io.mailtrap.model.request.emailcampaigns; + +import com.fasterxml.jackson.annotation.JsonFormat; +import io.mailtrap.model.AbstractModel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.OffsetDateTime; + +/** + * Request body for scheduling an email campaign. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ScheduleEmailCampaignRequest extends AbstractModel { + + /** + * When to send the campaign. Must be in the future and no more than 1 month ahead. + * + *

Serialized as an ISO-8601 string; the SDK's mapper otherwise renders date-times as + * numeric timestamps, which the API rejects. + */ + @JsonFormat(shape = JsonFormat.Shape.STRING) + private OffsetDateTime datetime; + +} diff --git a/src/main/java/io/mailtrap/model/request/emailcampaigns/TemplateAttributes.java b/src/main/java/io/mailtrap/model/request/emailcampaigns/TemplateAttributes.java new file mode 100644 index 0000000..b61bd16 --- /dev/null +++ b/src/main/java/io/mailtrap/model/request/emailcampaigns/TemplateAttributes.java @@ -0,0 +1,52 @@ +package io.mailtrap.model.request.emailcampaigns; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.mailtrap.model.AbstractModel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * Inline email template — the campaign's subject and design. Each campaign has exactly one + * template, created together with it. On update, the template is always edited in place, and + * only the sub-fields that are provided change. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class TemplateAttributes extends AbstractModel { + + /** + * Email subject line. Required when creating a campaign. Supports merge tags, e.g. + * {@code Hi {{first_name}}}. + */ + private String subject; + + /** + * HTML body of the email (the design). Optional for a draft; required before the campaign + * can be scheduled or started. Include an unsubscribe link via an anchor whose {@code href} + * contains the {@code __unsubscribe_url__} placeholder. + */ + @JsonProperty("body_html") + private String bodyHtml; + + /** + * Optional plain-text alternative of the email body. + */ + @JsonProperty("body_text") + private String bodyText; + + /** + * Bare names of the merge tags referenced in the subject/body, without the {@code {{ }}} + * delimiters — e.g. {@code ["first_name"]}. Replaced as a whole when provided. + */ + @JsonProperty("merge_tags") + private List mergeTags; + +} diff --git a/src/main/java/io/mailtrap/model/request/emailcampaigns/UpdateEmailCampaign.java b/src/main/java/io/mailtrap/model/request/emailcampaigns/UpdateEmailCampaign.java new file mode 100644 index 0000000..93d1c82 --- /dev/null +++ b/src/main/java/io/mailtrap/model/request/emailcampaigns/UpdateEmailCampaign.java @@ -0,0 +1,69 @@ +package io.mailtrap.model.request.emailcampaigns; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.mailtrap.model.AbstractModel; +import io.mailtrap.model.DeliveryMode; +import io.mailtrap.model.response.emailcampaigns.DeliveryOptions; +import io.mailtrap.model.response.emailcampaigns.ReplyTo; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * Attributes used to update a {@code draft} email campaign. All fields are optional; only the + * fields that are set are sent (PATCH semantics). Update accepts the same fields as create. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class UpdateEmailCampaign extends AbstractModel { + + private String name; + + /** + * UUID of the verified sending domain used for the campaign. + */ + @JsonProperty("mailsend_domain_id") + private String mailsendDomainId; + + @JsonProperty("from_display_name") + private String fromDisplayName; + + @JsonProperty("from_local_part") + private String fromLocalPart; + + @JsonProperty("reply_to") + private ReplyTo replyTo; + + @JsonProperty("template_attributes") + private TemplateAttributes templateAttributes; + + @JsonProperty("delivery_mode") + private DeliveryMode deliveryMode; + + /** + * Delivery throttling options. Applies when {@code deliveryMode} is + * {@link DeliveryMode#GRADUAL}. + */ + @JsonProperty("delivery_options") + private DeliveryOptions deliveryOptions; + + /** + * IDs of contact lists to send to. Treated as the full set of included lists. + */ + @JsonProperty("contact_list_ids") + private List contactListIds; + + /** + * IDs of contact segments to send to. Treated as the full set of included segments. + */ + @JsonProperty("contact_segment_ids") + private List contactSegmentIds; + +} diff --git a/src/main/java/io/mailtrap/model/response/emailcampaigns/CampaignRecipientError.java b/src/main/java/io/mailtrap/model/response/emailcampaigns/CampaignRecipientError.java new file mode 100644 index 0000000..5876ed7 --- /dev/null +++ b/src/main/java/io/mailtrap/model/response/emailcampaigns/CampaignRecipientError.java @@ -0,0 +1,17 @@ +package io.mailtrap.model.response.emailcampaigns; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +/** + * A per-recipient error recorded when campaign sending failed. + */ +@Data +public class CampaignRecipientError { + + private String message; + + @JsonProperty("rcpt_index") + private Integer rcptIndex; + +} diff --git a/src/main/java/io/mailtrap/model/response/emailcampaigns/CurrentStateMetadata.java b/src/main/java/io/mailtrap/model/response/emailcampaigns/CurrentStateMetadata.java new file mode 100644 index 0000000..249c136 --- /dev/null +++ b/src/main/java/io/mailtrap/model/response/emailcampaigns/CurrentStateMetadata.java @@ -0,0 +1,34 @@ +package io.mailtrap.model.response.emailcampaigns; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.time.OffsetDateTime; +import java.util.List; + +/** + * Metadata about the most recent campaign state transition. Which fields are present depends + * on the state. + */ +@Data +public class CurrentStateMetadata { + + private String reason; + + /** + * Last error message recorded for a failed campaign. + */ + private String error; + + /** + * Per-recipient errors recorded when sending failed. + */ + private List errors; + + /** + * When the campaign is scheduled to send. Present in the {@code scheduled} state. + */ + @JsonProperty("scheduled_at") + private OffsetDateTime scheduledAt; + +} diff --git a/src/main/java/io/mailtrap/model/response/emailcampaigns/DeliveryOptions.java b/src/main/java/io/mailtrap/model/response/emailcampaigns/DeliveryOptions.java new file mode 100644 index 0000000..c522bfd --- /dev/null +++ b/src/main/java/io/mailtrap/model/response/emailcampaigns/DeliveryOptions.java @@ -0,0 +1,24 @@ +package io.mailtrap.model.response.emailcampaigns; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.mailtrap.model.AbstractModel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Delivery throttling options. Used both on update input and on the campaign response. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class DeliveryOptions extends AbstractModel { + + @JsonProperty("emails_per_hour") + private Integer emailsPerHour; + +} diff --git a/src/main/java/io/mailtrap/model/response/emailcampaigns/EmailCampaign.java b/src/main/java/io/mailtrap/model/response/emailcampaigns/EmailCampaign.java new file mode 100644 index 0000000..ae041a1 --- /dev/null +++ b/src/main/java/io/mailtrap/model/response/emailcampaigns/EmailCampaign.java @@ -0,0 +1,99 @@ +package io.mailtrap.model.response.emailcampaigns; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.mailtrap.model.CampaignState; +import io.mailtrap.model.DeliveryMode; +import lombok.Data; + +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.util.List; + +/** + * An email marketing campaign. + * + *

Returned wrapped in a {@code data} envelope by the single-campaign endpoints and as an + * element of the list response. Some fields are conditional: the template's {@code bodyHtml} + * and {@code bodyText} are omitted from list items, and {@code lastStartedAt}/ + * {@code recipientTotalCount} are nullable. + */ +@Data +public class EmailCampaign { + + private Long id; + + /** + * Resource type discriminator: {@code "ContactsEmailCampaign"} targets contact + * lists/segments; {@code "RecipientsEmailCampaign"} targets an uploaded recipients list. + */ + private String type; + + /** + * UUID of the verified sending domain used for the campaign. + */ + @JsonProperty("mailsend_domain_id") + private String mailsendDomainId; + + @JsonProperty("mailsend_domain_name") + private String mailsendDomainName; + + private String name; + + @JsonProperty("from_local_part") + private String fromLocalPart; + + @JsonProperty("from_display_name") + private String fromDisplayName; + + @JsonProperty("reply_to") + private ReplyTo replyTo; + + @JsonProperty("current_state") + private CampaignState currentState; + + @JsonProperty("current_state_metadata") + private CurrentStateMetadata currentStateMetadata; + + @JsonProperty("created_at") + private OffsetDateTime createdAt; + + @JsonProperty("updated_at") + private OffsetDateTime updatedAt; + + @JsonProperty("last_started_at") + private OffsetDateTime lastStartedAt; + + /** + * Date the campaign was last started. Present only when the campaign has been started. + */ + @JsonProperty("last_started_at_date") + private LocalDate lastStartedAtDate; + + /** + * Total number of recipients targeted by the campaign. {@code null} until the audience is + * resolved. + */ + @JsonProperty("recipient_total_count") + private Integer recipientTotalCount; + + /** + * IDs of the contact lists included in the campaign's audience. + */ + @JsonProperty("contact_list_ids") + private List contactListIds; + + /** + * IDs of the contact segments included in the campaign's audience. + */ + @JsonProperty("contact_segment_ids") + private List contactSegmentIds; + + @JsonProperty("delivery_mode") + private DeliveryMode deliveryMode; + + @JsonProperty("delivery_options") + private DeliveryOptions deliveryOptions; + + private Template template; + +} diff --git a/src/main/java/io/mailtrap/model/response/emailcampaigns/EmailCampaignListResponse.java b/src/main/java/io/mailtrap/model/response/emailcampaigns/EmailCampaignListResponse.java new file mode 100644 index 0000000..30c45b1 --- /dev/null +++ b/src/main/java/io/mailtrap/model/response/emailcampaigns/EmailCampaignListResponse.java @@ -0,0 +1,18 @@ +package io.mailtrap.model.response.emailcampaigns; + +import lombok.Data; + +import java.util.List; + +/** + * Paginated list of email campaigns, wrapped as {@code data} alongside page-token pagination + * metadata. + */ +@Data +public class EmailCampaignListResponse { + + private List data; + + private Pagination pagination; + +} diff --git a/src/main/java/io/mailtrap/model/response/emailcampaigns/EmailCampaignResponse.java b/src/main/java/io/mailtrap/model/response/emailcampaigns/EmailCampaignResponse.java new file mode 100644 index 0000000..6ec0a81 --- /dev/null +++ b/src/main/java/io/mailtrap/model/response/emailcampaigns/EmailCampaignResponse.java @@ -0,0 +1,14 @@ +package io.mailtrap.model.response.emailcampaigns; + +import lombok.Data; + +/** + * A single email campaign wrapped in the {@code data} envelope, as returned by the create, + * get, update, and lifecycle action endpoints. + */ +@Data +public class EmailCampaignResponse { + + private EmailCampaign data; + +} diff --git a/src/main/java/io/mailtrap/model/response/emailcampaigns/EmailCampaignStats.java b/src/main/java/io/mailtrap/model/response/emailcampaigns/EmailCampaignStats.java new file mode 100644 index 0000000..aa93512 --- /dev/null +++ b/src/main/java/io/mailtrap/model/response/emailcampaigns/EmailCampaignStats.java @@ -0,0 +1,58 @@ +package io.mailtrap.model.response.emailcampaigns; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +/** + * Aggregated campaign performance metrics. Counts and rates are {@code 0} when the campaign + * has not been started. + */ +@Data +public class EmailCampaignStats { + + @JsonProperty("delivery_count") + private Integer deliveryCount; + + @JsonProperty("open_count") + private Integer openCount; + + @JsonProperty("click_count") + private Integer clickCount; + + @JsonProperty("bounce_count") + private Integer bounceCount; + + @JsonProperty("unsubscription_count") + private Integer unsubscriptionCount; + + @JsonProperty("sent_count") + private Integer sentCount; + + @JsonProperty("spam_count") + private Integer spamCount; + + @JsonProperty("message_count") + private Integer messageCount; + + @JsonProperty("reject_count") + private Integer rejectCount; + + @JsonProperty("delivery_rate") + private Double deliveryRate; + + @JsonProperty("open_rate") + private Double openRate; + + @JsonProperty("click_rate") + private Double clickRate; + + @JsonProperty("bounce_rate") + private Double bounceRate; + + @JsonProperty("spam_rate") + private Double spamRate; + + @JsonProperty("unsubscription_rate") + private Double unsubscriptionRate; + +} diff --git a/src/main/java/io/mailtrap/model/response/emailcampaigns/EmailCampaignStatsResponse.java b/src/main/java/io/mailtrap/model/response/emailcampaigns/EmailCampaignStatsResponse.java new file mode 100644 index 0000000..db0f68f --- /dev/null +++ b/src/main/java/io/mailtrap/model/response/emailcampaigns/EmailCampaignStatsResponse.java @@ -0,0 +1,13 @@ +package io.mailtrap.model.response.emailcampaigns; + +import lombok.Data; + +/** + * Aggregated campaign statistics wrapped in the {@code data} envelope. + */ +@Data +public class EmailCampaignStatsResponse { + + private EmailCampaignStats data; + +} diff --git a/src/main/java/io/mailtrap/model/response/emailcampaigns/Pagination.java b/src/main/java/io/mailtrap/model/response/emailcampaigns/Pagination.java new file mode 100644 index 0000000..d60745b --- /dev/null +++ b/src/main/java/io/mailtrap/model/response/emailcampaigns/Pagination.java @@ -0,0 +1,41 @@ +package io.mailtrap.model.response.emailcampaigns; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +/** + * Page-token pagination metadata returned with a list of email campaigns. + */ +@Data +public class Pagination { + + /** + * Current page number. + */ + private Integer token; + + /** + * Previous page number, or {@code null} on the first page. + */ + @JsonProperty("prev_token") + private Integer prevToken; + + /** + * Next page number, or {@code null} on the last page. + */ + @JsonProperty("next_token") + private Integer nextToken; + + @JsonProperty("first_url") + private String firstUrl; + + @JsonProperty("prev_url") + private String prevUrl; + + @JsonProperty("current_url") + private String currentUrl; + + @JsonProperty("next_url") + private String nextUrl; + +} diff --git a/src/main/java/io/mailtrap/model/response/emailcampaigns/ReplyTo.java b/src/main/java/io/mailtrap/model/response/emailcampaigns/ReplyTo.java new file mode 100644 index 0000000..7dfdaa9 --- /dev/null +++ b/src/main/java/io/mailtrap/model/response/emailcampaigns/ReplyTo.java @@ -0,0 +1,29 @@ +package io.mailtrap.model.response.emailcampaigns; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.mailtrap.model.AbstractModel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Reply-To address parts. Used both on request input and on the campaign response. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ReplyTo extends AbstractModel { + + @JsonProperty("display_name") + private String displayName; + + @JsonProperty("local_part") + private String localPart; + + private String domain; + +} diff --git a/src/main/java/io/mailtrap/model/response/emailcampaigns/Template.java b/src/main/java/io/mailtrap/model/response/emailcampaigns/Template.java new file mode 100644 index 0000000..479660c --- /dev/null +++ b/src/main/java/io/mailtrap/model/response/emailcampaigns/Template.java @@ -0,0 +1,29 @@ +package io.mailtrap.model.response.emailcampaigns; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.util.List; + +/** + * The template associated with an email campaign as returned in the campaign response. + * {@code bodyHtml} and {@code bodyText} are returned only on single-campaign responses; the + * list endpoint omits them. + */ +@Data +public class Template { + + private Long id; + + private String subject; + + @JsonProperty("merge_tags") + private List mergeTags; + + @JsonProperty("body_html") + private String bodyHtml; + + @JsonProperty("body_text") + private String bodyText; + +} diff --git a/src/test/java/io/mailtrap/api/emailcampaigns/EmailCampaignsImplTest.java b/src/test/java/io/mailtrap/api/emailcampaigns/EmailCampaignsImplTest.java new file mode 100644 index 0000000..fe2f744 --- /dev/null +++ b/src/test/java/io/mailtrap/api/emailcampaigns/EmailCampaignsImplTest.java @@ -0,0 +1,283 @@ +package io.mailtrap.api.emailcampaigns; + +import io.mailtrap.Constants; +import io.mailtrap.config.MailtrapConfig; +import io.mailtrap.factory.MailtrapClientFactory; +import io.mailtrap.model.CampaignState; +import io.mailtrap.model.DeliveryMode; +import io.mailtrap.model.request.emailcampaigns.CreateEmailCampaign; +import io.mailtrap.model.request.emailcampaigns.ScheduleEmailCampaignRequest; +import io.mailtrap.model.request.emailcampaigns.TemplateAttributes; +import io.mailtrap.model.request.emailcampaigns.UpdateEmailCampaign; +import io.mailtrap.model.response.emailcampaigns.DeliveryOptions; +import io.mailtrap.model.response.emailcampaigns.EmailCampaign; +import io.mailtrap.model.response.emailcampaigns.EmailCampaignListResponse; +import io.mailtrap.model.response.emailcampaigns.EmailCampaignResponse; +import io.mailtrap.model.response.emailcampaigns.EmailCampaignStatsResponse; +import io.mailtrap.model.response.emailcampaigns.ReplyTo; +import io.mailtrap.testutils.BaseTest; +import io.mailtrap.testutils.DataMock; +import io.mailtrap.testutils.TestHttpClient; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +class EmailCampaignsImplTest extends BaseTest { + + private static final String BODY_HTML = + "

Hi {{first_name}}!

Unsubscribe

"; + + private final long emailCampaignId = 4567L; + private final String mailsendDomainId = "d2313359-acb4-4b87-bce6-f5774f6a1e37"; + + private EmailCampaigns api; + + @BeforeEach + public void init() { + final TestHttpClient httpClient = new TestHttpClient(List.of( + DataMock.build(Constants.GENERAL_HOST + "/api/email_campaigns", + "GET", null, "api/emailcampaigns/listEmailCampaignsResponse.json"), + + DataMock.build(Constants.GENERAL_HOST + "/api/email_campaigns", + "GET", null, "api/emailcampaigns/listEmailCampaignsResponse.json", Map.of("search", "Spring")), + + DataMock.build(Constants.GENERAL_HOST + "/api/email_campaigns", + "POST", "api/emailcampaigns/createEmailCampaignRequest.json", "api/emailcampaigns/createEmailCampaignResponse.json"), + + DataMock.build(Constants.GENERAL_HOST + "/api/email_campaigns/" + emailCampaignId, + "GET", null, "api/emailcampaigns/getEmailCampaignResponse.json"), + + DataMock.build(Constants.GENERAL_HOST + "/api/email_campaigns/" + emailCampaignId, + "PATCH", "api/emailcampaigns/updateEmailCampaignRequest.json", "api/emailcampaigns/updateEmailCampaignResponse.json"), + + DataMock.build(Constants.GENERAL_HOST + "/api/email_campaigns/" + emailCampaignId, + "DELETE", null, null), + + DataMock.build(Constants.GENERAL_HOST + "/api/email_campaigns/" + emailCampaignId + "/start", + "POST", null, "api/emailcampaigns/startEmailCampaignResponse.json"), + + DataMock.build(Constants.GENERAL_HOST + "/api/email_campaigns/" + emailCampaignId + "/schedule", + "POST", "api/emailcampaigns/scheduleEmailCampaignRequest.json", "api/emailcampaigns/scheduleEmailCampaignResponse.json"), + + DataMock.build(Constants.GENERAL_HOST + "/api/email_campaigns/" + emailCampaignId + "/cancel", + "POST", null, "api/emailcampaigns/cancelEmailCampaignResponse.json"), + + DataMock.build(Constants.GENERAL_HOST + "/api/email_campaigns/" + emailCampaignId + "/terminate", + "POST", null, "api/emailcampaigns/terminateEmailCampaignResponse.json"), + + DataMock.build(Constants.GENERAL_HOST + "/api/email_campaigns/" + emailCampaignId + "/reset", + "POST", null, "api/emailcampaigns/resetEmailCampaignResponse.json"), + + DataMock.build(Constants.GENERAL_HOST + "/api/email_campaigns/" + emailCampaignId + "/stats", + "GET", null, "api/emailcampaigns/getEmailCampaignStatsResponse.json"), + + DataMock.build(Constants.GENERAL_HOST + "/api/email_campaigns/" + emailCampaignId + "/stats", + "GET", null, "api/emailcampaigns/getEmailCampaignStatsResponse.json", + Map.of("start_date", "2026-05-01", "end_date", "2026-05-31")) + )); + + final MailtrapConfig testConfig = new MailtrapConfig.Builder() + .httpClient(httpClient) + .token("dummy_token") + .build(); + + api = MailtrapClientFactory.createMailtrapClient(testConfig).emailCampaignsApi().emailCampaigns(); + } + + @Test + void test_getEmailCampaigns() { + final EmailCampaignListResponse response = api.getEmailCampaigns(null, null, null); + + assertNotNull(response); + assertEquals(2, response.getData().size()); + + final EmailCampaign first = response.getData().get(0); + assertEquals(emailCampaignId, first.getId()); + assertEquals("ContactsEmailCampaign", first.getType()); + assertEquals(mailsendDomainId, first.getMailsendDomainId()); + assertEquals(CampaignState.DRAFT, first.getCurrentState()); + assertEquals(DeliveryMode.RAPID, first.getDeliveryMode()); + assertEquals(List.of(55L, 56L), first.getContactListIds()); + assertEquals(List.of(12L), first.getContactSegmentIds()); + // template bodies are omitted from list responses + assertNull(first.getTemplate().getBodyHtml()); + assertEquals(List.of("first_name"), first.getTemplate().getMergeTags()); + + final EmailCampaign second = response.getData().get(1); + assertEquals(CampaignState.FAILED, second.getCurrentState()); + assertEquals("Invalid recipient address", second.getCurrentStateMetadata().getErrors().get(0).getMessage()); + assertEquals(0, second.getCurrentStateMetadata().getErrors().get(0).getRcptIndex()); + + assertNotNull(response.getPagination()); + assertEquals(1, response.getPagination().getToken()); + assertNull(response.getPagination().getPrevToken()); + assertEquals(2, response.getPagination().getNextToken()); + } + + @Test + void test_getEmailCampaigns_filtersBySearch() { + final EmailCampaignListResponse response = api.getEmailCampaigns(null, "Spring", null); + + assertNotNull(response); + assertEquals(2, response.getData().size()); + } + + @Test + void test_createEmailCampaign() { + final CreateEmailCampaign request = CreateEmailCampaign.builder() + .name("Spring Sale") + .mailsendDomainId(mailsendDomainId) + .fromDisplayName("Acme Marketing") + .fromLocalPart("news") + .replyTo(ReplyTo.builder() + .displayName("Acme Support") + .localPart("support") + .domain("acme.com") + .build()) + .templateAttributes(TemplateAttributes.builder() + .subject("Spring is here — 30% off") + .bodyHtml(BODY_HTML) + .mergeTags(List.of("first_name")) + .build()) + .deliveryMode(DeliveryMode.RAPID) + .contactListIds(List.of(55L, 56L)) + .contactSegmentIds(List.of(12L)) + .build(); + + final EmailCampaignResponse response = api.createEmailCampaign(request); + + assertNotNull(response); + final EmailCampaign campaign = response.getData(); + assertEquals(emailCampaignId, campaign.getId()); + assertEquals("Spring Sale", campaign.getName()); + assertEquals(mailsendDomainId, campaign.getMailsendDomainId()); + assertEquals(CampaignState.DRAFT, campaign.getCurrentState()); + assertEquals(DeliveryMode.RAPID, campaign.getDeliveryMode()); + assertEquals(789L, campaign.getTemplate().getId()); + assertEquals(BODY_HTML, campaign.getTemplate().getBodyHtml()); + // audience is resolved asynchronously + assertNull(campaign.getRecipientTotalCount()); + } + + @Test + void test_getEmailCampaign() { + final EmailCampaignResponse response = api.getEmailCampaign(emailCampaignId); + + assertNotNull(response); + final EmailCampaign campaign = response.getData(); + assertEquals(emailCampaignId, campaign.getId()); + assertEquals("acme.com", campaign.getMailsendDomainName()); + assertEquals("Acme Support", campaign.getReplyTo().getDisplayName()); + assertEquals(DeliveryMode.GRADUAL, campaign.getDeliveryMode()); + assertEquals(1000, campaign.getDeliveryOptions().getEmailsPerHour()); + assertEquals(1500, campaign.getRecipientTotalCount()); + assertEquals("Hi {{first_name}}! Unsubscribe: __unsubscribe_url__", campaign.getTemplate().getBodyText()); + } + + @Test + void test_updateEmailCampaign() { + final UpdateEmailCampaign request = UpdateEmailCampaign.builder() + .name("Spring Sale (updated)") + .templateAttributes(TemplateAttributes.builder() + .subject("New subject") + .bodyHtml(BODY_HTML) + .mergeTags(List.of("first_name")) + .build()) + .deliveryMode(DeliveryMode.GRADUAL) + .deliveryOptions(DeliveryOptions.builder().emailsPerHour(1000).build()) + .contactListIds(List.of(55L, 56L)) + .contactSegmentIds(List.of(12L)) + .build(); + + final EmailCampaignResponse response = api.updateEmailCampaign(emailCampaignId, request); + + assertNotNull(response); + final EmailCampaign campaign = response.getData(); + assertEquals(emailCampaignId, campaign.getId()); + assertEquals("Spring Sale (updated)", campaign.getName()); + assertEquals(CampaignState.DRAFT, campaign.getCurrentState()); + assertEquals(DeliveryMode.GRADUAL, campaign.getDeliveryMode()); + assertEquals("New subject", campaign.getTemplate().getSubject()); + } + + @Test + void test_deleteEmailCampaign() { + // delete returns 204 No Content with no body + assertDoesNotThrow(() -> api.deleteEmailCampaign(emailCampaignId)); + } + + @Test + void test_startEmailCampaign() { + final EmailCampaignResponse response = api.startEmailCampaign(emailCampaignId); + + assertNotNull(response); + assertEquals(CampaignState.STARTED, response.getData().getCurrentState()); + assertNotNull(response.getData().getLastStartedAt()); + } + + @Test + void test_scheduleEmailCampaign() { + final ScheduleEmailCampaignRequest request = new ScheduleEmailCampaignRequest( + OffsetDateTime.of(2026, 6, 1, 9, 0, 0, 0, ZoneOffset.UTC)); + + final EmailCampaignResponse response = api.scheduleEmailCampaign(emailCampaignId, request); + + assertNotNull(response); + assertEquals(CampaignState.SCHEDULED, response.getData().getCurrentState()); + assertEquals(OffsetDateTime.of(2026, 6, 1, 9, 0, 0, 0, ZoneOffset.UTC), + response.getData().getCurrentStateMetadata().getScheduledAt()); + } + + @Test + void test_cancelEmailCampaign() { + final EmailCampaignResponse response = api.cancelEmailCampaign(emailCampaignId); + + assertNotNull(response); + assertEquals(CampaignState.DRAFT, response.getData().getCurrentState()); + } + + @Test + void test_terminateEmailCampaign() { + final EmailCampaignResponse response = api.terminateEmailCampaign(emailCampaignId); + + assertNotNull(response); + assertEquals(CampaignState.TERMINATING, response.getData().getCurrentState()); + } + + @Test + void test_resetEmailCampaign() { + final EmailCampaignResponse response = api.resetEmailCampaign(emailCampaignId); + + assertNotNull(response); + assertEquals(CampaignState.DRAFT, response.getData().getCurrentState()); + } + + @Test + void test_getEmailCampaignStats() { + final EmailCampaignStatsResponse response = api.getEmailCampaignStats(emailCampaignId); + + assertNotNull(response); + assertEquals(1450, response.getData().getDeliveryCount()); + assertEquals(820, response.getData().getOpenCount()); + assertEquals(0.9667, response.getData().getDeliveryRate()); + assertEquals(0.5655, response.getData().getOpenRate()); + } + + @Test + void test_getEmailCampaignStats_withDateWindow() { + final EmailCampaignStatsResponse response = + api.getEmailCampaignStats(emailCampaignId, "2026-05-01", "2026-05-31"); + + assertNotNull(response); + assertEquals(1450, response.getData().getDeliveryCount()); + } +} diff --git a/src/test/resources/api/emailcampaigns/cancelEmailCampaignResponse.json b/src/test/resources/api/emailcampaigns/cancelEmailCampaignResponse.json new file mode 100644 index 0000000..0747c1f --- /dev/null +++ b/src/test/resources/api/emailcampaigns/cancelEmailCampaignResponse.json @@ -0,0 +1,30 @@ +{ + "data": { + "id": 4567, + "type": "ContactsEmailCampaign", + "mailsend_domain_id": "d2313359-acb4-4b87-bce6-f5774f6a1e37", + "mailsend_domain_name": "acme.com", + "name": "Spring Sale", + "from_local_part": "news", + "from_display_name": "Acme Marketing", + "current_state": "draft", + "current_state_metadata": {}, + "created_at": "2026-05-01T10:15:00.000Z", + "updated_at": "2026-05-02T10:00:00.000Z", + "last_started_at": null, + "recipient_total_count": 1500, + "contact_list_ids": [55, 56], + "contact_segment_ids": [12], + "delivery_mode": "rapid", + "delivery_options": { + "emails_per_hour": null + }, + "template": { + "id": 789, + "subject": "Spring is here — 30% off", + "merge_tags": ["first_name"], + "body_html": "

Hi {{first_name}}!

Unsubscribe

", + "body_text": null + } + } +} diff --git a/src/test/resources/api/emailcampaigns/createEmailCampaignRequest.json b/src/test/resources/api/emailcampaigns/createEmailCampaignRequest.json new file mode 100644 index 0000000..d741249 --- /dev/null +++ b/src/test/resources/api/emailcampaigns/createEmailCampaignRequest.json @@ -0,0 +1,19 @@ +{ + "name": "Spring Sale", + "mailsend_domain_id": "d2313359-acb4-4b87-bce6-f5774f6a1e37", + "from_display_name": "Acme Marketing", + "from_local_part": "news", + "reply_to": { + "display_name": "Acme Support", + "local_part": "support", + "domain": "acme.com" + }, + "template_attributes": { + "subject": "Spring is here — 30% off", + "body_html": "

Hi {{first_name}}!

Unsubscribe

", + "merge_tags": ["first_name"] + }, + "delivery_mode": "rapid", + "contact_list_ids": [55, 56], + "contact_segment_ids": [12] +} diff --git a/src/test/resources/api/emailcampaigns/createEmailCampaignResponse.json b/src/test/resources/api/emailcampaigns/createEmailCampaignResponse.json new file mode 100644 index 0000000..6a070e9 --- /dev/null +++ b/src/test/resources/api/emailcampaigns/createEmailCampaignResponse.json @@ -0,0 +1,35 @@ +{ + "data": { + "id": 4567, + "type": "ContactsEmailCampaign", + "mailsend_domain_id": "d2313359-acb4-4b87-bce6-f5774f6a1e37", + "mailsend_domain_name": "acme.com", + "name": "Spring Sale", + "from_local_part": "news", + "from_display_name": "Acme Marketing", + "reply_to": { + "display_name": "Acme Support", + "local_part": "support", + "domain": "acme.com" + }, + "current_state": "draft", + "current_state_metadata": {}, + "created_at": "2026-05-01T10:15:00.000Z", + "updated_at": "2026-05-01T10:15:00.000Z", + "last_started_at": null, + "recipient_total_count": null, + "contact_list_ids": [55, 56], + "contact_segment_ids": [12], + "delivery_mode": "rapid", + "delivery_options": { + "emails_per_hour": null + }, + "template": { + "id": 789, + "subject": "Spring is here — 30% off", + "merge_tags": ["first_name"], + "body_html": "

Hi {{first_name}}!

Unsubscribe

", + "body_text": null + } + } +} diff --git a/src/test/resources/api/emailcampaigns/getEmailCampaignResponse.json b/src/test/resources/api/emailcampaigns/getEmailCampaignResponse.json new file mode 100644 index 0000000..d7ea5c6 --- /dev/null +++ b/src/test/resources/api/emailcampaigns/getEmailCampaignResponse.json @@ -0,0 +1,35 @@ +{ + "data": { + "id": 4567, + "type": "ContactsEmailCampaign", + "mailsend_domain_id": "d2313359-acb4-4b87-bce6-f5774f6a1e37", + "mailsend_domain_name": "acme.com", + "name": "Spring Sale", + "from_local_part": "news", + "from_display_name": "Acme Marketing", + "reply_to": { + "display_name": "Acme Support", + "local_part": "support", + "domain": "acme.com" + }, + "current_state": "draft", + "current_state_metadata": {}, + "created_at": "2026-05-01T10:15:00.000Z", + "updated_at": "2026-05-02T09:00:00.000Z", + "last_started_at": null, + "recipient_total_count": 1500, + "contact_list_ids": [55, 56], + "contact_segment_ids": [12], + "delivery_mode": "gradual", + "delivery_options": { + "emails_per_hour": 1000 + }, + "template": { + "id": 789, + "subject": "Spring is here — 30% off", + "merge_tags": ["first_name"], + "body_html": "

Hi {{first_name}}!

Unsubscribe

", + "body_text": "Hi {{first_name}}! Unsubscribe: __unsubscribe_url__" + } + } +} diff --git a/src/test/resources/api/emailcampaigns/getEmailCampaignStatsResponse.json b/src/test/resources/api/emailcampaigns/getEmailCampaignStatsResponse.json new file mode 100644 index 0000000..a4b029f --- /dev/null +++ b/src/test/resources/api/emailcampaigns/getEmailCampaignStatsResponse.json @@ -0,0 +1,19 @@ +{ + "data": { + "delivery_count": 1450, + "open_count": 820, + "click_count": 310, + "bounce_count": 30, + "unsubscription_count": 12, + "sent_count": 1500, + "spam_count": 5, + "message_count": 1500, + "reject_count": 20, + "delivery_rate": 0.9667, + "open_rate": 0.5655, + "click_rate": 0.2138, + "bounce_rate": 0.02, + "spam_rate": 0.0033, + "unsubscription_rate": 0.0083 + } +} diff --git a/src/test/resources/api/emailcampaigns/listEmailCampaignsResponse.json b/src/test/resources/api/emailcampaigns/listEmailCampaignsResponse.json new file mode 100644 index 0000000..467f676 --- /dev/null +++ b/src/test/resources/api/emailcampaigns/listEmailCampaignsResponse.json @@ -0,0 +1,79 @@ +{ + "data": [ + { + "id": 4567, + "type": "ContactsEmailCampaign", + "mailsend_domain_id": "d2313359-acb4-4b87-bce6-f5774f6a1e37", + "mailsend_domain_name": "acme.com", + "name": "Spring Sale", + "from_local_part": "news", + "from_display_name": "Acme Marketing", + "reply_to": { + "display_name": "Acme Support", + "local_part": "support", + "domain": "acme.com" + }, + "current_state": "draft", + "current_state_metadata": {}, + "created_at": "2026-05-01T10:15:00.000Z", + "updated_at": "2026-05-02T09:00:00.000Z", + "last_started_at": null, + "recipient_total_count": null, + "contact_list_ids": [55, 56], + "contact_segment_ids": [12], + "delivery_mode": "rapid", + "delivery_options": { + "emails_per_hour": null + }, + "template": { + "id": 789, + "subject": "Spring is here — 30% off", + "merge_tags": ["first_name"] + } + }, + { + "id": 4568, + "type": "RecipientsEmailCampaign", + "mailsend_domain_id": "d2313359-acb4-4b87-bce6-f5774f6a1e37", + "mailsend_domain_name": "acme.com", + "name": "Summer Sale", + "from_local_part": "news", + "from_display_name": "Acme Marketing", + "current_state": "failed", + "current_state_metadata": { + "error": "Invalid recipient address", + "errors": [ + { + "message": "Invalid recipient address", + "rcpt_index": 0 + } + ] + }, + "created_at": "2026-05-03T10:15:00.000Z", + "updated_at": "2026-05-04T09:00:00.000Z", + "last_started_at": "2026-05-03T12:00:00.000Z", + "last_started_at_date": "2026-05-03", + "recipient_total_count": 2000, + "contact_list_ids": [], + "contact_segment_ids": [], + "delivery_mode": "gradual", + "delivery_options": { + "emails_per_hour": 1000 + }, + "template": { + "id": 790, + "subject": "Summer is here", + "merge_tags": [] + } + } + ], + "pagination": { + "token": 1, + "prev_token": null, + "next_token": 2, + "first_url": "https://mailtrap.io/api/email_campaigns?per_page=50&token=1", + "prev_url": null, + "current_url": "https://mailtrap.io/api/email_campaigns?per_page=50&token=1", + "next_url": "https://mailtrap.io/api/email_campaigns?per_page=50&token=2" + } +} diff --git a/src/test/resources/api/emailcampaigns/resetEmailCampaignResponse.json b/src/test/resources/api/emailcampaigns/resetEmailCampaignResponse.json new file mode 100644 index 0000000..dadc6d5 --- /dev/null +++ b/src/test/resources/api/emailcampaigns/resetEmailCampaignResponse.json @@ -0,0 +1,30 @@ +{ + "data": { + "id": 4567, + "type": "ContactsEmailCampaign", + "mailsend_domain_id": "d2313359-acb4-4b87-bce6-f5774f6a1e37", + "mailsend_domain_name": "acme.com", + "name": "Spring Sale", + "from_local_part": "news", + "from_display_name": "Acme Marketing", + "current_state": "draft", + "current_state_metadata": {}, + "created_at": "2026-05-01T10:15:00.000Z", + "updated_at": "2026-05-02T11:00:00.000Z", + "last_started_at": null, + "recipient_total_count": 1500, + "contact_list_ids": [55, 56], + "contact_segment_ids": [12], + "delivery_mode": "rapid", + "delivery_options": { + "emails_per_hour": null + }, + "template": { + "id": 789, + "subject": "Spring is here — 30% off", + "merge_tags": ["first_name"], + "body_html": "

Hi {{first_name}}!

Unsubscribe

", + "body_text": null + } + } +} diff --git a/src/test/resources/api/emailcampaigns/scheduleEmailCampaignRequest.json b/src/test/resources/api/emailcampaigns/scheduleEmailCampaignRequest.json new file mode 100644 index 0000000..35b6df9 --- /dev/null +++ b/src/test/resources/api/emailcampaigns/scheduleEmailCampaignRequest.json @@ -0,0 +1,3 @@ +{ + "datetime": "2026-06-01T09:00:00Z" +} diff --git a/src/test/resources/api/emailcampaigns/scheduleEmailCampaignResponse.json b/src/test/resources/api/emailcampaigns/scheduleEmailCampaignResponse.json new file mode 100644 index 0000000..cb25a58 --- /dev/null +++ b/src/test/resources/api/emailcampaigns/scheduleEmailCampaignResponse.json @@ -0,0 +1,32 @@ +{ + "data": { + "id": 4567, + "type": "ContactsEmailCampaign", + "mailsend_domain_id": "d2313359-acb4-4b87-bce6-f5774f6a1e37", + "mailsend_domain_name": "acme.com", + "name": "Spring Sale", + "from_local_part": "news", + "from_display_name": "Acme Marketing", + "current_state": "scheduled", + "current_state_metadata": { + "scheduled_at": "2026-06-01T09:00:00.000Z" + }, + "created_at": "2026-05-01T10:15:00.000Z", + "updated_at": "2026-05-02T09:00:00.000Z", + "last_started_at": null, + "recipient_total_count": 1500, + "contact_list_ids": [55, 56], + "contact_segment_ids": [12], + "delivery_mode": "rapid", + "delivery_options": { + "emails_per_hour": null + }, + "template": { + "id": 789, + "subject": "Spring is here — 30% off", + "merge_tags": ["first_name"], + "body_html": "

Hi {{first_name}}!

Unsubscribe

", + "body_text": null + } + } +} diff --git a/src/test/resources/api/emailcampaigns/startEmailCampaignResponse.json b/src/test/resources/api/emailcampaigns/startEmailCampaignResponse.json new file mode 100644 index 0000000..a67b29d --- /dev/null +++ b/src/test/resources/api/emailcampaigns/startEmailCampaignResponse.json @@ -0,0 +1,31 @@ +{ + "data": { + "id": 4567, + "type": "ContactsEmailCampaign", + "mailsend_domain_id": "d2313359-acb4-4b87-bce6-f5774f6a1e37", + "mailsend_domain_name": "acme.com", + "name": "Spring Sale", + "from_local_part": "news", + "from_display_name": "Acme Marketing", + "current_state": "started", + "current_state_metadata": {}, + "created_at": "2026-05-01T10:15:00.000Z", + "updated_at": "2026-05-03T12:00:00.000Z", + "last_started_at": "2026-05-03T12:00:00.000Z", + "last_started_at_date": "2026-05-03", + "recipient_total_count": 1500, + "contact_list_ids": [55, 56], + "contact_segment_ids": [12], + "delivery_mode": "rapid", + "delivery_options": { + "emails_per_hour": null + }, + "template": { + "id": 789, + "subject": "Spring is here — 30% off", + "merge_tags": ["first_name"], + "body_html": "

Hi {{first_name}}!

Unsubscribe

", + "body_text": null + } + } +} diff --git a/src/test/resources/api/emailcampaigns/terminateEmailCampaignResponse.json b/src/test/resources/api/emailcampaigns/terminateEmailCampaignResponse.json new file mode 100644 index 0000000..ca9f3ae --- /dev/null +++ b/src/test/resources/api/emailcampaigns/terminateEmailCampaignResponse.json @@ -0,0 +1,31 @@ +{ + "data": { + "id": 4567, + "type": "ContactsEmailCampaign", + "mailsend_domain_id": "d2313359-acb4-4b87-bce6-f5774f6a1e37", + "mailsend_domain_name": "acme.com", + "name": "Spring Sale", + "from_local_part": "news", + "from_display_name": "Acme Marketing", + "current_state": "terminating", + "current_state_metadata": {}, + "created_at": "2026-05-01T10:15:00.000Z", + "updated_at": "2026-05-03T12:30:00.000Z", + "last_started_at": "2026-05-03T12:00:00.000Z", + "last_started_at_date": "2026-05-03", + "recipient_total_count": 1500, + "contact_list_ids": [55, 56], + "contact_segment_ids": [12], + "delivery_mode": "rapid", + "delivery_options": { + "emails_per_hour": null + }, + "template": { + "id": 789, + "subject": "Spring is here — 30% off", + "merge_tags": ["first_name"], + "body_html": "

Hi {{first_name}}!

Unsubscribe

", + "body_text": null + } + } +} diff --git a/src/test/resources/api/emailcampaigns/updateEmailCampaignRequest.json b/src/test/resources/api/emailcampaigns/updateEmailCampaignRequest.json new file mode 100644 index 0000000..3ce63aa --- /dev/null +++ b/src/test/resources/api/emailcampaigns/updateEmailCampaignRequest.json @@ -0,0 +1,14 @@ +{ + "name": "Spring Sale (updated)", + "template_attributes": { + "subject": "New subject", + "body_html": "

Hi {{first_name}}!

Unsubscribe

", + "merge_tags": ["first_name"] + }, + "delivery_mode": "gradual", + "delivery_options": { + "emails_per_hour": 1000 + }, + "contact_list_ids": [55, 56], + "contact_segment_ids": [12] +} diff --git a/src/test/resources/api/emailcampaigns/updateEmailCampaignResponse.json b/src/test/resources/api/emailcampaigns/updateEmailCampaignResponse.json new file mode 100644 index 0000000..4c56e99 --- /dev/null +++ b/src/test/resources/api/emailcampaigns/updateEmailCampaignResponse.json @@ -0,0 +1,35 @@ +{ + "data": { + "id": 4567, + "type": "ContactsEmailCampaign", + "mailsend_domain_id": "d2313359-acb4-4b87-bce6-f5774f6a1e37", + "mailsend_domain_name": "acme.com", + "name": "Spring Sale (updated)", + "from_local_part": "news", + "from_display_name": "Acme Marketing", + "reply_to": { + "display_name": "Acme Support", + "local_part": "support", + "domain": "acme.com" + }, + "current_state": "draft", + "current_state_metadata": {}, + "created_at": "2026-05-01T10:15:00.000Z", + "updated_at": "2026-05-02T09:00:00.000Z", + "last_started_at": null, + "recipient_total_count": 1500, + "contact_list_ids": [55, 56], + "contact_segment_ids": [12], + "delivery_mode": "gradual", + "delivery_options": { + "emails_per_hour": 1000 + }, + "template": { + "id": 789, + "subject": "New subject", + "merge_tags": ["first_name"], + "body_html": "

Hi {{first_name}}!

Unsubscribe

", + "body_text": null + } + } +}