proxymail-qa 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -100,6 +100,18 @@ const { delivered, invalid } = await qa.variants(address);
100
100
 
101
101
  // After the app changes the test account's password, keep the saved one in step.
102
102
  await qa.setPassword(address, { password: 'the-new-test-password' });
103
+
104
+ // Check the email that arrived: SPF, DKIM, DMARC, local or http links, where
105
+ // its link goes, a stated expiry and more. Verdicts only, never its content.
106
+ const result = await qa.checkEmail(address, { type: 'link', expectHost: 'staging.example.com' });
107
+ expect(result.failed).toEqual([]);
108
+
109
+ // A run: this client's calls add what they saw, for a report on the pull
110
+ // request. In GitHub Actions, the Proxy Mail QA action does this for you and
111
+ // the client picks the run up from PROXYMAIL_QA_RUN.
112
+ await qa.startRun({ name: 'Sign-up tests' });
113
+ // …the tests…
114
+ await qa.finishRun({ failed: testsFailed });
103
115
  ```
104
116
 
105
117
  When a wait comes back empty, the error says why as far as Proxy Mail QA can
package/dist/index.d.ts CHANGED
@@ -2,6 +2,7 @@ export interface ClientOptions {
2
2
  apiKey?: string;
3
3
  baseUrl?: string;
4
4
  fetch?: typeof fetch;
5
+ run?: string | null;
5
6
  }
6
7
  export interface Persona {
7
8
  first_name: string | null;
@@ -132,11 +133,67 @@ export interface CountOptions {
132
133
  subtype?: CountSubtype | CountSubtype[];
133
134
  }
134
135
  export type CountSubtype = 'otp' | 'coupon' | 'verify' | 'magic_signin' | 'invite' | 'reset' | 'other' | 'any';
136
+ export type CheckId = 'spf' | 'dkim' | 'dmarc' | 'local_links' | 'plain_http' | 'expected_host' | 'expiry' | 'plain_text' | 'size' | 'images_only';
137
+ export interface EmailCheck {
138
+ check: CheckId;
139
+ status: 'pass' | 'fail' | 'warn' | 'skip';
140
+ detail: string;
141
+ }
142
+ export interface EmailChecks {
143
+ address: string;
144
+ email: {
145
+ id: string;
146
+ received_at: string;
147
+ service: string | null;
148
+ service_domain: string | null;
149
+ type: 'code' | 'link' | null;
150
+ subtype: string | null;
151
+ };
152
+ passed: boolean;
153
+ failed: CheckId[];
154
+ warnings: CheckId[];
155
+ checks: EmailCheck[];
156
+ }
157
+ export interface CheckOptions extends CountOptions {
158
+ emailId?: string;
159
+ expectHost?: string;
160
+ }
135
161
  export interface NoEmail {
136
162
  address: string;
137
163
  since: string;
138
164
  until: string;
139
165
  }
166
+ export interface RunInput {
167
+ name?: string;
168
+ source?: 'ci' | 'agent' | 'api';
169
+ meta?: {
170
+ repository?: string;
171
+ branch?: string;
172
+ commit?: string;
173
+ pull_request?: number;
174
+ workflow?: string;
175
+ job_url?: string;
176
+ agent?: string;
177
+ };
178
+ }
179
+ export interface RunEvent {
180
+ at: string;
181
+ kind: 'address' | 'email' | 'missing' | 'none' | 'count' | 'checks' | 'note';
182
+ address: string | null;
183
+ failed: boolean;
184
+ detail: Record<string, unknown>;
185
+ }
186
+ export interface Run {
187
+ id: string;
188
+ name: string;
189
+ source: 'ci' | 'agent' | 'api';
190
+ status: 'running' | 'passed' | 'failed';
191
+ meta: NonNullable<RunInput['meta']>;
192
+ started_at: string;
193
+ finished_at: string | null;
194
+ url: string;
195
+ events?: RunEvent[];
196
+ }
140
197
  export declare class ProxyMailQAError extends Error {
141
198
  readonly status: number;
142
199
  readonly code: string;
@@ -149,6 +206,7 @@ export declare class ProxyMailQA {
149
206
  private readonly apiKey;
150
207
  private readonly baseUrl;
151
208
  private readonly fetcher;
209
+ run: string | null;
152
210
  constructor(options?: ClientOptions);
153
211
  private request;
154
212
  project(): Promise<Record<string, unknown>>;
@@ -171,6 +229,7 @@ export declare class ProxyMailQA {
171
229
  limit?: number;
172
230
  }): Promise<Record<string, unknown>[]>;
173
231
  countEmails(address: string, options?: CountOptions): Promise<EmailCount>;
232
+ checkEmail(address: string, options?: CheckOptions): Promise<EmailChecks>;
174
233
  expectNoEmail(address: string, options?: CountOptions & {
175
234
  timeout?: number;
176
235
  }): Promise<NoEmail>;
@@ -191,5 +250,19 @@ export declare class ProxyMailQA {
191
250
  label: string;
192
251
  secret: string;
193
252
  }>;
253
+ startRun(input?: RunInput): Promise<Run>;
254
+ getRun(id?: string): Promise<Run>;
255
+ runReport(id?: string): Promise<string>;
256
+ listRuns(limit?: number): Promise<Run[]>;
257
+ addRunNote(text: string, options?: {
258
+ failed?: boolean;
259
+ address?: string;
260
+ run?: string;
261
+ }): Promise<void>;
262
+ finishRun(options?: {
263
+ failed?: boolean;
264
+ run?: string;
265
+ }): Promise<Run>;
266
+ private requireRun;
194
267
  private wait;
195
268
  }
package/dist/index.js CHANGED
@@ -40,23 +40,34 @@ export class ProxyMailQA {
40
40
  apiKey;
41
41
  baseUrl;
42
42
  fetcher;
43
+ // The run this client's calls add to, if any.
44
+ run;
43
45
  constructor(options = {}) {
44
- const fromEnv = globalThis.process?.env?.PROXYMAIL_QA_API_KEY;
45
- const apiKey = options.apiKey ?? fromEnv;
46
+ const env = globalThis.process?.env;
47
+ const apiKey = options.apiKey ?? env?.PROXYMAIL_QA_API_KEY;
46
48
  if (!apiKey)
47
49
  throw new Error('Pass apiKey or set PROXYMAIL_QA_API_KEY. Create a key in Project settings → API keys.');
48
50
  this.apiKey = apiKey;
49
51
  this.baseUrl = (options.baseUrl ?? 'https://api.proxymail.qa').replace(/\/+$/, '');
50
52
  this.fetcher = options.fetch ?? globalThis.fetch.bind(globalThis);
53
+ this.run = options.run === undefined ? env?.PROXYMAIL_QA_RUN?.trim() || null : options.run;
51
54
  }
52
- async request(method, path, body) {
55
+ // retry: this call is asked again if nothing arrives, so the run only
56
+ // records the last try's "nothing arrived".
57
+ async request(method, path, body, retry = false) {
53
58
  const response = await this.fetcher(`${this.baseUrl}/v1${path}`, {
54
59
  method,
55
- headers: { Authorization: `Bearer ${this.apiKey}`, ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
60
+ headers: {
61
+ Authorization: `Bearer ${this.apiKey}`,
62
+ ...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
63
+ ...(this.run ? { 'Proxy-Mail-QA-Run': this.run, ...(retry ? { 'Proxy-Mail-QA-Run-Retry': 'true' } : {}) } : {}),
64
+ },
56
65
  body: body === undefined ? undefined : JSON.stringify(body),
57
66
  });
58
67
  if (response.status === 204)
59
68
  return undefined;
69
+ if ((response.headers.get('Content-Type') ?? '').startsWith('text/markdown') && response.ok)
70
+ return (await response.text());
60
71
  const payload = (await response.json().catch(() => ({})));
61
72
  if (!response.ok) {
62
73
  throw new ProxyMailQAError(response.status, payload.error ?? 'error', payload.message ?? `Request failed with ${response.status}`, payload.hint, payload.refused, payload.help);
@@ -141,6 +152,18 @@ export class ProxyMailQA {
141
152
  countEmails(address, options = {}) {
142
153
  return this.request('GET', `/addresses/${encodeURIComponent(address)}/count${countQuery(options)}`);
143
154
  }
155
+ // Checks one email the address received: SPF, DKIM and DMARC, links to
156
+ // localhost or plain http, where its link goes (expectHost), a stated
157
+ // expiry, a plain-text part, size and image-only emails. Doesn't wait: wait
158
+ // for the email first. Throws ProxyMailQAError (404) when none matches.
159
+ checkEmail(address, options = {}) {
160
+ const extra = {};
161
+ if (options.emailId)
162
+ extra.email_id = options.emailId;
163
+ if (options.expectHost)
164
+ extra.expect_host = options.expectHost;
165
+ return this.request('GET', `/addresses/${encodeURIComponent(address)}/checks${countQuery(options, extra)}`);
166
+ }
144
167
  // Resolves when no matching email arrives within timeout seconds (default
145
168
  // 30; longer repeats the request), for checks such as "an unsubscribed user
146
169
  // gets nothing". Throws ProxyMailQAError (status 409, code email_arrived)
@@ -152,7 +175,8 @@ export class ProxyMailQA {
152
175
  for (;;) {
153
176
  const left = Math.max(0, Math.min(MAX_WAIT_SECONDS, Math.ceil((deadline - Date.now()) / 1000)));
154
177
  const query = countQuery({ ...options, since }, { timeout: String(left) });
155
- const result = await this.request('GET', `/addresses/${encodeURIComponent(address)}/none${query}`);
178
+ const more = Date.now() + left * 1000 < deadline - 500;
179
+ const result = await this.request('GET', `/addresses/${encodeURIComponent(address)}/none${query}`, undefined, more);
156
180
  // The server's since, so later requests watch from the same moment
157
181
  // whatever this machine's clock says.
158
182
  since = result.since;
@@ -175,6 +199,46 @@ export class ProxyMailQA {
175
199
  savedPassword(address, label = 'password') {
176
200
  return this.request('GET', `/addresses/${encodeURIComponent(address)}/password?label=${encodeURIComponent(label)}`);
177
201
  }
202
+ // Starts a run: a CI job or agent session whose calls add what they saw
203
+ // (addresses, emails and how long after since, codes, a link's site and
204
+ // path, checks, and emails that never came). This client's later calls add
205
+ // to it; to add calls from other processes, pass them its id as
206
+ // PROXYMAIL_QA_RUN. The GitHub Action starts and finishes one for you.
207
+ async startRun(input = {}) {
208
+ const run = await this.request('POST', '/runs', input);
209
+ this.run = run.id;
210
+ return run;
211
+ }
212
+ getRun(id = this.requireRun()) {
213
+ return this.request('GET', `/runs/${encodeURIComponent(id)}`);
214
+ }
215
+ // The report in Markdown, for a pull request comment or a job summary.
216
+ runReport(id = this.requireRun()) {
217
+ return this.request('GET', `/runs/${encodeURIComponent(id)}?format=markdown`);
218
+ }
219
+ listRuns(limit) {
220
+ return this.request('GET', `/runs${limit ? `?limit=${limit}` : ''}`).then((r) => r.runs);
221
+ }
222
+ // A line of your own in the run, such as a test that failed for a reason
223
+ // of its own. failed: true fails the run.
224
+ addRunNote(text, options = {}) {
225
+ const id = options.run ?? this.requireRun();
226
+ return this.request('POST', `/runs/${encodeURIComponent(id)}/notes`, { text, failed: options.failed ?? false, address: options.address });
227
+ }
228
+ // Passed unless an email was missing, came when none should have, failed
229
+ // a check, or failed says your tests failed.
230
+ async finishRun(options = {}) {
231
+ const id = options.run ?? this.requireRun();
232
+ const run = await this.request('POST', `/runs/${encodeURIComponent(id)}/finish`, { failed: options.failed ?? false });
233
+ if (this.run === id)
234
+ this.run = null;
235
+ return run;
236
+ }
237
+ requireRun() {
238
+ if (!this.run)
239
+ throw new Error('No run: call startRun first, or set PROXYMAIL_QA_RUN.');
240
+ return this.run;
241
+ }
178
242
  async wait(address, kind, options, type = '', app) {
179
243
  const total = options.timeout ?? MAX_WAIT_SECONDS;
180
244
  const deadline = Date.now() + total * 1000;
@@ -190,8 +254,9 @@ export class ProxyMailQA {
190
254
  query.set('type', type);
191
255
  if (app !== undefined)
192
256
  query.set('app', String(app));
257
+ const more = Date.now() + left * 1000 < deadline - 500;
193
258
  try {
194
- return await this.request('GET', `/addresses/${encodeURIComponent(address)}/${kind}?${query}`);
259
+ return await this.request('GET', `/addresses/${encodeURIComponent(address)}/${kind}?${query}`, undefined, more);
195
260
  }
196
261
  catch (error) {
197
262
  const timedOut = error instanceof ProxyMailQAError && error.status === 404 && TIMED_OUT.test(error.message);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "proxymail-qa",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Disposable test inboxes for Playwright, Cypress and scripts: create an address, then wait for its verification code or link.",
5
5
  "license": "MIT",
6
6
  "author": "Proxy Mail QA <hello@proxymail.qa> (https://proxymail.qa)",