Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 2 additions & 6 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,12 @@
# GitHub personal access token (repo scope required)
GITHUB_TOKEN=ghp_your_token_here

# Email provider: "resend" (default) or "gmail"
# Email provider: "resend" (only supported value)
# EMAIL_PROVIDER=resend

# Resend API key (required when EMAIL_PROVIDER=resend or unset)
# Resend API key (required)
RESEND_API_KEY=re_your_key_here

# Gmail OAuth refresh token (required when EMAIL_PROVIDER=gmail)
# Obtain via: npx @atriumn/cryyer auth gmail
# GMAIL_REFRESH_TOKEN=1//your_refresh_token_here

# Default sender email address
FROM_EMAIL=updates@yourdomain.com

Expand Down
15 changes: 5 additions & 10 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,8 @@ Key modules:
| `summarize.ts` | Builds prompt with product voice, calls LLM provider, parses JSON `{subject, body}` response |
| `subscriber-store.ts` | SubscriberStore interface and factory; adapters for Supabase, JSON file, GitHub Gist, Google Sheets. Supports `getSubscribers`, `recordEmailSent`, `addSubscriber`, `removeSubscriber`. |
| `mcp.ts` | MCP server entry point; 9 tools + 1 prompt for draft review and subscriber management |
| `email-provider.ts` | EmailProvider interface and factory; adapters for Resend, Gmail |
| `auth.ts` | `cryyer auth gmail` — OAuth 2.0 flow for Gmail authorization |
| `gmail-oauth.ts` | Google OAuth client ID/secret constants |
| `email-provider.ts` | EmailProvider interface and factory; Resend adapter |
| `auth.ts` | `cryyer auth linkedin` — OAuth 2.0 flow for LinkedIn authorization |
| `send.ts` | Builds email messages (`sendEmails`), delegates sending to EmailProvider |
| `draft-file.ts` | CLI: `cryyer draft-file` — gather activity → LLM draft → write YAML front matter file |
| `send-file.ts` | CLI: `cryyer send-file` — read YAML front matter draft → send emails to subscribers |
Expand Down Expand Up @@ -137,11 +136,8 @@ GITHUB_REPOSITORY # Set by GitHub Actions
### Email Provider Configuration

```
EMAIL_PROVIDER # "resend" (default) or "gmail"
# Resend (default):
RESEND_API_KEY # Required when EMAIL_PROVIDER=resend (or unset)
# Gmail:
GMAIL_REFRESH_TOKEN # Required when EMAIL_PROVIDER=gmail; set via "cryyer auth gmail"
EMAIL_PROVIDER # "resend" (only supported value; default)
RESEND_API_KEY # Required
```

### Subscriber Store Configuration
Expand Down Expand Up @@ -232,9 +228,8 @@ Wraps `cryyer send-file`. Maps all credential inputs to env vars for email and s
| `product` | yes | — | Product ID |
| `draft-path` | yes | — | Path to the draft markdown file |
| `from-email` | yes | — | Sender email address |
| `email-provider` | no | `resend` | Email provider (resend, gmail) |
| `email-provider` | no | `resend` | Email provider (resend) |
| `email-api-key` | no | — | Resend API key |
| `gmail-refresh-token` | no | — | Gmail OAuth refresh token |
| `from-name` | no | `Cryyer Updates` | Sender display name |
| `subscriber-store` | no | `json` | Subscriber store (json, supabase, google-sheets) |
| `supabase-url` | no | — | Supabase project URL |
Expand Down
27 changes: 0 additions & 27 deletions src/__tests__/check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,33 +280,6 @@ describe('checkEmailConfig', () => {
expect(result.message).toContain('FROM_EMAIL');
});

it('passes when gmail vars are set', () => {
process.env['EMAIL_PROVIDER'] = 'gmail';
process.env['GMAIL_REFRESH_TOKEN'] = 'token';
process.env['FROM_EMAIL'] = 'from@test.com';
const result = checkEmailConfig();
expect(result.passed).toBe(true);
expect(result.message).toContain('GMAIL_REFRESH_TOKEN');
});

it('fails when gmail refresh token is missing', () => {
process.env['EMAIL_PROVIDER'] = 'gmail';
delete process.env['GMAIL_REFRESH_TOKEN'];
process.env['FROM_EMAIL'] = 'from@test.com';
const result = checkEmailConfig();
expect(result.passed).toBe(false);
expect(result.message).toContain('GMAIL_REFRESH_TOKEN');
});

it('fails when gmail FROM_EMAIL is missing', () => {
process.env['EMAIL_PROVIDER'] = 'gmail';
process.env['GMAIL_REFRESH_TOKEN'] = 'token';
delete process.env['FROM_EMAIL'];
const result = checkEmailConfig();
expect(result.passed).toBe(false);
expect(result.message).toContain('FROM_EMAIL');
});

it('fails for unknown email provider', () => {
process.env['EMAIL_PROVIDER'] = 'mailgun';
const result = checkEmailConfig();
Expand Down
110 changes: 1 addition & 109 deletions src/__tests__/email-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,7 @@ vi.mock('resend', () => ({
},
}));

vi.mock('google-auth-library', () => ({
OAuth2Client: class MockOAuth2Client {
setCredentials() {}
async getAccessToken() { return { token: 'mock-access-token' }; }
},
}));

import { ResendProvider, GmailProvider, createEmailProvider } from '../email-provider.js';
import { ResendProvider, createEmailProvider } from '../email-provider.js';
import type { EmailMessage } from '../email-provider.js';

describe('ResendProvider', () => {
Expand Down Expand Up @@ -85,115 +78,14 @@ describe('createEmailProvider', () => {
expect(provider).toBeInstanceOf(ResendProvider);
});

it('creates gmail provider when EMAIL_PROVIDER=gmail', () => {
process.env.EMAIL_PROVIDER = 'gmail';
process.env.GMAIL_REFRESH_TOKEN = 'test_refresh_token';
const provider = createEmailProvider();
expect(provider).toBeInstanceOf(GmailProvider);
});

it('throws when resend key is missing', () => {
delete process.env.RESEND_API_KEY;
delete process.env.EMAIL_PROVIDER;
expect(() => createEmailProvider()).toThrow('Missing RESEND_API_KEY');
});

it('throws when gmail refresh token is missing', () => {
process.env.EMAIL_PROVIDER = 'gmail';
delete process.env.GMAIL_REFRESH_TOKEN;
expect(() => createEmailProvider()).toThrow('Missing GMAIL_REFRESH_TOKEN');
});

it('throws on unknown provider', () => {
process.env.EMAIL_PROVIDER = 'sendgrid';
expect(() => createEmailProvider()).toThrow('Unknown email provider');
});

it('accepts override provider', () => {
process.env.GMAIL_REFRESH_TOKEN = 'test_token';
const provider = createEmailProvider({ provider: 'gmail' });
expect(provider).toBeInstanceOf(GmailProvider);
});
});

describe('GmailProvider', () => {
afterEach(() => {
vi.restoreAllMocks();
});

it('sends emails via Gmail API and returns stats', async () => {
const mockFetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ id: 'msg-1' }), { status: 200 })
);

const provider = new GmailProvider('mock-refresh-token');
const emails: EmailMessage[] = [
{ from: 'sender@test.com', to: 'a@test.com', subject: 'Test', html: '<p>Hi</p>' },
];

const result = await provider.sendBatch(emails);
expect(result.sent).toBe(1);
expect(result.failed).toBe(0);

expect(mockFetch).toHaveBeenCalledWith(
'https://gmail.googleapis.com/gmail/v1/users/me/messages/send',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({ Authorization: 'Bearer mock-access-token' }),
})
);
});

it('records failures when Gmail API returns error', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response('Bad Request', { status: 400 })
);

const provider = new GmailProvider('mock-refresh-token');
const emails: EmailMessage[] = [
{ from: 'sender@test.com', to: 'a@test.com', subject: 'Test', html: '<p>Hi</p>' },
];

const result = await provider.sendBatch(emails);
expect(result.sent).toBe(0);
expect(result.failed).toBe(1);
expect(result.failures[0].error).toContain('400');
});

it('records failures when fetch throws', async () => {
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('Network down'));

const provider = new GmailProvider('mock-refresh-token');
const emails: EmailMessage[] = [
{ from: 'sender@test.com', to: 'a@test.com', subject: 'Test', html: '<p>Hi</p>' },
];

const result = await provider.sendBatch(emails);
expect(result.sent).toBe(0);
expect(result.failed).toBe(1);
expect(result.failures[0].error).toContain('Network down');
});

it('includes replyTo and custom headers in RFC2822 message', async () => {
let capturedBody = '';
vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
capturedBody = JSON.parse((init as RequestInit).body as string).raw;
return new Response(JSON.stringify({ id: 'msg-1' }), { status: 200 });
});

const provider = new GmailProvider('mock-refresh-token');
await provider.sendBatch([{
from: 'sender@test.com',
to: 'a@test.com',
subject: 'Test',
html: '<p>Hi</p>',
replyTo: 'reply@test.com',
headers: { 'X-Custom': 'value' },
}]);

// Decode base64url
const decoded = Buffer.from(capturedBody.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString();
expect(decoded).toContain('Reply-To: reply@test.com');
expect(decoded).toContain('X-Custom: value');
});
});
13 changes: 0 additions & 13 deletions src/__tests__/init-workflows.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,13 +86,6 @@ describe('buildSendWorkflowContent', () => {
expect(content).not.toContain('GMAIL_REFRESH_TOKEN');
});

it('includes gmail credentials for gmail provider', () => {
const content = buildSendWorkflowContent('acme-cli', 'gmail', 'json');
expect(content).toContain('email-provider: gmail');
expect(content).toContain('secrets.GMAIL_REFRESH_TOKEN');
expect(content).not.toContain('RESEND_API_KEY');
});

it('includes no store credentials for json store', () => {
const content = buildSendWorkflowContent('acme-cli', 'resend', 'json');
expect(content).toContain('subscriber-store: json');
Expand Down Expand Up @@ -171,12 +164,6 @@ describe('buildSendUpdateWorkflowContent', () => {
expect(content).not.toContain('GMAIL_REFRESH_TOKEN');
});

it('includes gmail credentials for gmail provider', () => {
const content = buildSendUpdateWorkflowContent('acme-cli', 'gmail', 'json');
expect(content).toContain('GMAIL_REFRESH_TOKEN');
expect(content).not.toContain('RESEND_API_KEY');
});

it('includes supabase credentials for supabase store', () => {
const content = buildSendUpdateWorkflowContent('acme-cli', 'resend', 'supabase');
expect(content).toContain('SUPABASE_URL');
Expand Down
39 changes: 2 additions & 37 deletions src/__tests__/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
};
});

import { sanitizeId, buildEnvContent, buildSubscribersJson, buildGitignoreContent, buildDraftWorkflowContent, buildSendWorkflowContent, parseInitFlags, main } from '../init.js';

Check warning on line 19 in src/__tests__/init.test.ts

View workflow job for this annotation

GitHub Actions / Lint, Typecheck, Test

'buildSendWorkflowContent' is defined but never used

Check warning on line 19 in src/__tests__/init.test.ts

View workflow job for this annotation

GitHub Actions / Lint, Typecheck, Test

'buildDraftWorkflowContent' is defined but never used
import type { InitAnswers } from '../init.js';
import { createInterface } from 'readline/promises';
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from 'fs';
Expand All @@ -29,7 +29,7 @@
'--voice', 'Casual',
'--llm', 'openai',
'--subscriber-store', 'supabase',
'--email-provider', 'gmail',
'--email-provider', 'resend',
'--from-email', 'hi@acme.dev',
'--yes',
'--pipeline', 'weekly',
Expand All @@ -40,7 +40,7 @@
voice: 'Casual',
llm: 'openai',
subscriberStore: 'supabase',
emailProvider: 'gmail',
emailProvider: 'resend',
fromEmail: 'hi@acme.dev',
yes: true,
pipeline: 'weekly',
Expand Down Expand Up @@ -124,13 +124,6 @@
expect(content).not.toContain('GOOGLE_SHEETS');
});

it('omits RESEND_API_KEY when email provider is gmail', () => {
const content = buildEnvContent({ ...baseAnswers, emailProvider: 'gmail', resendApiKey: undefined });
expect(content).toContain('EMAIL_PROVIDER=gmail');
expect(content).not.toContain('RESEND_API_KEY');
expect(content).toContain('FROM_EMAIL=updates@acme.dev');
});

it('includes supabase vars when store is supabase', () => {
const content = buildEnvContent({
...baseAnswers,
Expand Down Expand Up @@ -475,34 +468,6 @@
await expect(main()).rejects.toThrow('GitHub token is required');
});

it('handles Gmail email provider selection (skips Resend key)', async () => {
setupFreshDir();
makeRl([
'Acme CLI', 'acme/acme-cli', 'Casual',
'1', 'sk-ant-test', // anthropic
'1', // JSON store
'2', // Gmail
'ghp_test', // github token (no resend key prompt)
'updates@acme.dev', // from email
'n', // skip workflows
]);

await main();

expect(writeFileSync).toHaveBeenCalledWith(
expect.stringContaining('.env'),
expect.stringContaining('EMAIL_PROVIDER=gmail'),
'utf-8'
);
// Should not contain RESEND_API_KEY
const envCalls = (writeFileSync as Mock).mock.calls.filter(
(call) => typeof call[0] === 'string' && call[0].endsWith('.env')
);
expect(envCalls.length).toBeGreaterThan(0);
const envContent = envCalls[0][1] as string;
expect(envContent).not.toContain('RESEND_API_KEY');
});

it('creates weekly workflow files when user selects weekly pipeline', async () => {
setupFreshDir();
makeRl(freshAnswers);
Expand Down
Loading
Loading