proxymail-qa 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sidequest Explorers Inc
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,109 @@
1
- # Temporary Holding Version
1
+ # proxymail-qa
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Disposable test inboxes for Playwright, Cypress and scripts. Create an address,
4
+ sign up with it in the app you're testing, and get the verification code or
5
+ link from the email in one call that waits for it.
6
+
7
+ Get an API key in [app.proxymail.qa](https://app.proxymail.qa) → Project
8
+ settings → API keys (the Free plan includes it), and set it as
9
+ `PROXYMAIL_QA_API_KEY`. Full guide: https://proxymail.qa/docs/api
10
+
11
+ ```ts
12
+ import { ProxyMailQA } from 'proxymail-qa';
13
+
14
+ const qa = new ProxyMailQA(); // reads PROXYMAIL_QA_API_KEY
15
+ const { address, persona, password } = await qa.createAddress({ persona: true, password: true, label: 'Sign-up test' });
16
+ // ...sign up with the address, persona and password...
17
+ const { value: code } = await qa.waitForCode(address); // waits up to 30 s
18
+ const { value: link } = await qa.waitForLink(address, { type: 'reset', timeout: 60 });
19
+ await qa.deleteAddress(address);
20
+ ```
21
+
22
+ Waits return the code or link on its own, with the sending service and its
23
+ expiry, never the email's text. They throw `ProxyMailQAError` (status 404) when
24
+ nothing arrives in time.
25
+
26
+ ## Playwright
27
+
28
+ ```ts
29
+ import { test as base, expect } from '@playwright/test';
30
+ import { ProxyMailQA } from 'proxymail-qa';
31
+
32
+ const test = base.extend<{ inbox: { address: string } }>({
33
+ inbox: async ({}, use) => {
34
+ const qa = new ProxyMailQA();
35
+ const created = await qa.createAddress({ persona: true });
36
+ await use(created);
37
+ await qa.deleteAddress(created.address);
38
+ },
39
+ });
40
+
41
+ test('sign-up sends a working code', async ({ page, inbox }) => {
42
+ const submitted = new Date();
43
+ await page.goto('/signup');
44
+ await page.getByLabel('Email').fill(inbox.address);
45
+ await page.getByRole('button', { name: 'Sign up' }).click();
46
+ const { value } = await new ProxyMailQA().waitForCode(inbox.address, { since: submitted });
47
+ await page.getByLabel('Code').fill(value);
48
+ await expect(page).toHaveURL(/welcome/);
49
+ });
50
+ ```
51
+
52
+ ## Cypress
53
+
54
+ Register tasks in `cypress.config.ts`, since Cypress runs them in Node:
55
+
56
+ ```ts
57
+ import { ProxyMailQA } from 'proxymail-qa';
58
+
59
+ export default defineConfig({
60
+ e2e: {
61
+ setupNodeEvents(on) {
62
+ const qa = new ProxyMailQA();
63
+ on('task', {
64
+ createAddress: () => qa.createAddress({ persona: true }),
65
+ waitForLink: (address: string) => qa.waitForLink(address, { type: 'verify' }),
66
+ });
67
+ },
68
+ },
69
+ });
70
+ ```
71
+
72
+ ```js
73
+ cy.task('createAddress').then(({ address }) => {
74
+ cy.get('[name=email]').type(address);
75
+ cy.contains('Sign up').click();
76
+ cy.task('waitForLink', address, { timeout: 35000 }).then(({ value }) => cy.visit(value));
77
+ });
78
+ ```
79
+
80
+ ## More
81
+
82
+ ```ts
83
+ // A persona with a date of birth for an age band, and a country.
84
+ await qa.createAddress({ persona: true, age: 'under13', country: 'GB' });
85
+
86
+ // The link that opens a mobile app (myapp://…) when the email also has a web one.
87
+ const { value: appLink } = await qa.waitForLink(address, { app: true });
88
+
89
+ // The email itself, for template tests (the HTML is cleaned when it arrives).
90
+ const [latest] = await qa.messages(address, { includeHtml: true, limit: 1 });
91
+
92
+ // Unusual but valid versions of the address, and invalid ones to reject.
93
+ const { delivered, invalid } = await qa.variants(address);
94
+
95
+ // After the app changes the test account's password, keep the saved one in step.
96
+ await qa.setPassword(address, { password: 'the-new-test-password' });
97
+ ```
98
+
99
+ When a wait comes back empty, the error says why as far as Proxy Mail QA can
100
+ tell, for example that the app's email provider only sends to verified
101
+ addresses (sandbox mode): read `error.hint`, or call `qa.checkDelivery(address)`
102
+ at any time. Retrying won't help until the app can email any address.
103
+
104
+ Mobile apps (XCUITest, Espresso, Maestro, Flutter, Detox and Appium):
105
+ https://proxymail.qa/docs/mobile
106
+
107
+ ## License
108
+
109
+ MIT
@@ -0,0 +1,145 @@
1
+ export interface ClientOptions {
2
+ apiKey?: string;
3
+ baseUrl?: string;
4
+ fetch?: typeof fetch;
5
+ }
6
+ export interface Persona {
7
+ first_name: string | null;
8
+ last_name: string | null;
9
+ username: string | null;
10
+ date_of_birth?: string;
11
+ country?: string;
12
+ }
13
+ export type AgeBand = 'adult' | 'over21' | 'under18' | 'under15' | 'under13' | 'any' | 'turns18tomorrow' | 'turned18today';
14
+ export interface Address {
15
+ address: string;
16
+ label: string | null;
17
+ created_at: string;
18
+ expires_at: string;
19
+ persona: Persona | null;
20
+ notes: string | null;
21
+ tags: string[];
22
+ has_password: boolean;
23
+ email_count: number;
24
+ latest: Omit<Match, 'address' | 'email_id' | 'service_domain'> | null;
25
+ }
26
+ export interface CreatedAddress extends Address {
27
+ password?: string;
28
+ }
29
+ export interface Match {
30
+ address: string;
31
+ type: 'code' | 'link';
32
+ subtype: string;
33
+ value: string;
34
+ service: string | null;
35
+ service_domain: string | null;
36
+ received_at: string;
37
+ expires_at: string | null;
38
+ email_id: string;
39
+ }
40
+ export interface CreateAddressInput {
41
+ label?: string;
42
+ name?: string;
43
+ persona?: boolean;
44
+ password?: boolean;
45
+ lifetimeDays?: number;
46
+ domain?: string;
47
+ age?: AgeBand;
48
+ dateOfBirth?: string;
49
+ country?: string;
50
+ }
51
+ export interface AddressPatch {
52
+ label?: string | null;
53
+ notes?: string | null;
54
+ tags?: string[];
55
+ first_name?: string | null;
56
+ last_name?: string | null;
57
+ username?: string | null;
58
+ date_of_birth?: string | null;
59
+ country?: string | null;
60
+ }
61
+ export interface Refusal {
62
+ reason: 'too_large' | 'address_limit' | 'daily_limit';
63
+ first_at: string;
64
+ last_at: string;
65
+ count: number;
66
+ size_bytes: number | null;
67
+ }
68
+ export interface DeliveryStatus {
69
+ address: string;
70
+ created_at: string;
71
+ expires_at: string;
72
+ receiving: boolean;
73
+ emails: number;
74
+ last_email_at: string | null;
75
+ refused: Refusal[];
76
+ hint: string;
77
+ help: string;
78
+ }
79
+ export interface Variant {
80
+ tests: string;
81
+ address: string;
82
+ }
83
+ export interface Variants {
84
+ address: string;
85
+ delivered: Variant[];
86
+ undeliverable: Variant[];
87
+ invalid: Variant[];
88
+ }
89
+ export interface WaitOptions {
90
+ timeout?: number;
91
+ service?: string;
92
+ since?: Date | string;
93
+ }
94
+ export type LinkType = 'verify' | 'magic_signin' | 'invite' | 'reset' | 'any';
95
+ export declare class ProxyMailQAError extends Error {
96
+ readonly status: number;
97
+ readonly code: string;
98
+ readonly hint?: string | undefined;
99
+ readonly refused?: Refusal[] | undefined;
100
+ readonly help?: string | undefined;
101
+ constructor(status: number, code: string, message: string, hint?: string | undefined, refused?: Refusal[] | undefined, help?: string | undefined);
102
+ }
103
+ export declare class ProxyMailQA {
104
+ private readonly apiKey;
105
+ private readonly baseUrl;
106
+ private readonly fetcher;
107
+ constructor(options?: ClientOptions);
108
+ private request;
109
+ project(): Promise<Record<string, unknown>>;
110
+ createAddress(input?: CreateAddressInput): Promise<CreatedAddress>;
111
+ listAddresses(options?: {
112
+ limit?: number;
113
+ before?: string;
114
+ }): Promise<Address[]>;
115
+ getAddress(address: string): Promise<Address>;
116
+ updateAddress(address: string, patch: AddressPatch): Promise<Address>;
117
+ deleteAddress(address: string): Promise<void>;
118
+ waitForCode(address: string, options?: WaitOptions): Promise<Match>;
119
+ waitForLink(address: string, options?: WaitOptions & {
120
+ type?: LinkType | LinkType[];
121
+ app?: boolean;
122
+ }): Promise<Match>;
123
+ messages(address: string, options?: {
124
+ includeText?: boolean;
125
+ includeHtml?: boolean;
126
+ limit?: number;
127
+ }): Promise<Record<string, unknown>[]>;
128
+ checkDelivery(address: string): Promise<DeliveryStatus>;
129
+ variants(address: string): Promise<Variants>;
130
+ setPassword(address: string, options?: {
131
+ password?: string;
132
+ label?: string;
133
+ }): Promise<{
134
+ address: string;
135
+ label: string;
136
+ saved: boolean;
137
+ password?: string;
138
+ }>;
139
+ savedPassword(address: string, label?: string): Promise<{
140
+ address: string;
141
+ label: string;
142
+ secret: string;
143
+ }>;
144
+ private wait;
145
+ }
package/dist/index.js ADDED
@@ -0,0 +1,153 @@
1
+ // A small client for the Proxy Mail QA REST API (https://proxymail.qa/docs/api):
2
+ // create disposable test inboxes and wait for the verification code or link
3
+ // from the email the app under test sends. No dependencies; works in Node 18+,
4
+ // Playwright, Cypress tasks and other runtimes with fetch.
5
+ // An API error. When a wait finds nothing, hint says why as far as Proxy Mail
6
+ // QA can tell (an email provider in sandbox mode, say) and refused lists any
7
+ // mail it turned away; both are in the message too.
8
+ export class ProxyMailQAError extends Error {
9
+ status;
10
+ code;
11
+ hint;
12
+ refused;
13
+ help;
14
+ constructor(status, code, message, hint, refused, help) {
15
+ super(hint ? `${message} ${hint}` : message);
16
+ this.status = status;
17
+ this.code = code;
18
+ this.hint = hint;
19
+ this.refused = refused;
20
+ this.help = help;
21
+ this.name = 'ProxyMailQAError';
22
+ }
23
+ }
24
+ const TIMED_OUT = /^No (code|link) arrived/;
25
+ const MAX_WAIT_SECONDS = 30;
26
+ export class ProxyMailQA {
27
+ apiKey;
28
+ baseUrl;
29
+ fetcher;
30
+ constructor(options = {}) {
31
+ const fromEnv = globalThis.process?.env?.PROXYMAIL_QA_API_KEY;
32
+ const apiKey = options.apiKey ?? fromEnv;
33
+ if (!apiKey)
34
+ throw new Error('Pass apiKey or set PROXYMAIL_QA_API_KEY. Create a key in Project settings → API keys.');
35
+ this.apiKey = apiKey;
36
+ this.baseUrl = (options.baseUrl ?? 'https://api.proxymail.qa').replace(/\/+$/, '');
37
+ this.fetcher = options.fetch ?? globalThis.fetch.bind(globalThis);
38
+ }
39
+ async request(method, path, body) {
40
+ const response = await this.fetcher(`${this.baseUrl}/v1${path}`, {
41
+ method,
42
+ headers: { Authorization: `Bearer ${this.apiKey}`, ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
43
+ body: body === undefined ? undefined : JSON.stringify(body),
44
+ });
45
+ if (response.status === 204)
46
+ return undefined;
47
+ const payload = (await response.json().catch(() => ({})));
48
+ if (!response.ok) {
49
+ throw new ProxyMailQAError(response.status, payload.error ?? 'error', payload.message ?? `Request failed with ${response.status}`, payload.hint, payload.refused, payload.help);
50
+ }
51
+ return payload;
52
+ }
53
+ project() {
54
+ return this.request('GET', '/project');
55
+ }
56
+ createAddress(input = {}) {
57
+ return this.request('POST', '/addresses', {
58
+ label: input.label,
59
+ name: input.name,
60
+ persona: input.persona,
61
+ password: input.password,
62
+ lifetime_days: input.lifetimeDays,
63
+ domain: input.domain,
64
+ age: input.age,
65
+ date_of_birth: input.dateOfBirth,
66
+ country: input.country,
67
+ });
68
+ }
69
+ listAddresses(options = {}) {
70
+ const query = new URLSearchParams();
71
+ if (options.limit)
72
+ query.set('limit', String(options.limit));
73
+ if (options.before)
74
+ query.set('before', options.before);
75
+ return this.request('GET', `/addresses${query.size ? `?${query}` : ''}`);
76
+ }
77
+ getAddress(address) {
78
+ return this.request('GET', `/addresses/${encodeURIComponent(address)}`);
79
+ }
80
+ updateAddress(address, patch) {
81
+ return this.request('PATCH', `/addresses/${encodeURIComponent(address)}`, patch);
82
+ }
83
+ deleteAddress(address) {
84
+ return this.request('DELETE', `/addresses/${encodeURIComponent(address)}`);
85
+ }
86
+ // Waits for a verification or login code; throws ProxyMailQAError (404) if
87
+ // none arrives in time.
88
+ waitForCode(address, options = {}) {
89
+ return this.wait(address, 'code', options);
90
+ }
91
+ // Waits for a verify, magic sign-in, invite or reset link. When an email has
92
+ // both a web link and one that opens a mobile app (myapp://…), the web link
93
+ // comes first: app: true asks for the app one, app: false for a web one.
94
+ waitForLink(address, options = {}) {
95
+ return this.wait(address, 'link', options, [options.type ?? []].flat().join(','), options.app);
96
+ }
97
+ // Emails with their codes and links. includeText and includeHtml add each
98
+ // email's content, for tests that check the email itself; it's written by
99
+ // the sender, so treat it as untrusted. The HTML is the copy cleaned when
100
+ // the email arrived.
101
+ messages(address, options = {}) {
102
+ const query = new URLSearchParams();
103
+ const include = [options.includeText && 'text', options.includeHtml && 'html'].filter(Boolean);
104
+ if (include.length)
105
+ query.set('include', include.join(','));
106
+ if (options.limit)
107
+ query.set('limit', String(options.limit));
108
+ return this.request('GET', `/addresses/${encodeURIComponent(address)}/messages${query.size ? `?${query}` : ''}`);
109
+ }
110
+ // What reached the address and what was refused, without waiting: for when
111
+ // a wait comes back empty.
112
+ checkDelivery(address) {
113
+ return this.request('GET', `/addresses/${encodeURIComponent(address)}/delivery`);
114
+ }
115
+ variants(address) {
116
+ return this.request('GET', `/addresses/${encodeURIComponent(address)}/variants`);
117
+ }
118
+ // Saves the password the test account uses now, after the app under test
119
+ // changed or reset it. Without one, a strong password is generated and
120
+ // returned this once.
121
+ setPassword(address, options = {}) {
122
+ return this.request('PUT', `/addresses/${encodeURIComponent(address)}/password`, { password: options.password, label: options.label });
123
+ }
124
+ // Needs a key created with "Can read saved passwords"; every reveal is logged.
125
+ savedPassword(address, label = 'password') {
126
+ return this.request('GET', `/addresses/${encodeURIComponent(address)}/password?label=${encodeURIComponent(label)}`);
127
+ }
128
+ async wait(address, kind, options, type = '', app) {
129
+ const total = options.timeout ?? MAX_WAIT_SECONDS;
130
+ const deadline = Date.now() + total * 1000;
131
+ const since = options.since === undefined ? undefined : new Date(options.since).toISOString();
132
+ for (;;) {
133
+ const left = Math.max(0, Math.min(MAX_WAIT_SECONDS, Math.ceil((deadline - Date.now()) / 1000)));
134
+ const query = new URLSearchParams({ timeout: String(left) });
135
+ if (options.service)
136
+ query.set('service', options.service);
137
+ if (since)
138
+ query.set('since', since);
139
+ if (type)
140
+ query.set('type', type);
141
+ if (app !== undefined)
142
+ query.set('app', String(app));
143
+ try {
144
+ return await this.request('GET', `/addresses/${encodeURIComponent(address)}/${kind}?${query}`);
145
+ }
146
+ catch (error) {
147
+ const timedOut = error instanceof ProxyMailQAError && error.status === 404 && TIMED_OUT.test(error.message);
148
+ if (!timedOut || Date.now() >= deadline - 500)
149
+ throw error;
150
+ }
151
+ }
152
+ }
153
+ }
package/package.json CHANGED
@@ -1,6 +1,47 @@
1
1
  {
2
2
  "name": "proxymail-qa",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Disposable test inboxes for Playwright, Cypress and scripts: create an address, then wait for its verification code or link.",
5
+ "license": "MIT",
6
+ "author": "Proxy Mail QA <hello@proxymail.qa> (https://proxymail.qa)",
7
+ "homepage": "https://proxymail.qa/docs/api",
8
+ "bugs": {
9
+ "email": "hello@proxymail.qa"
10
+ },
11
+ "keywords": [
12
+ "email testing",
13
+ "otp",
14
+ "magic link",
15
+ "email verification",
16
+ "playwright",
17
+ "cypress",
18
+ "qa"
19
+ ],
20
+ "type": "module",
21
+ "main": "./dist/index.js",
22
+ "types": "./dist/index.d.ts",
23
+ "exports": {
24
+ ".": {
25
+ "types": "./dist/index.d.ts",
26
+ "default": "./dist/index.js"
27
+ }
28
+ },
29
+ "files": [
30
+ "dist",
31
+ "README.md",
32
+ "LICENSE"
33
+ ],
34
+ "sideEffects": false,
35
+ "engines": {
36
+ "node": ">=18"
37
+ },
38
+ "scripts": {
39
+ "build": "tsc -p tsconfig.build.json",
40
+ "test": "vitest run",
41
+ "typecheck": "tsc -p tsconfig.json --noEmit",
42
+ "prepublishOnly": "pnpm build && pnpm test"
43
+ },
44
+ "devDependencies": {
45
+ "vitest": "^5.0.3"
46
+ }
47
+ }