diff --git a/.env.example b/.env.example index a9000e5..d18abcd 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 80ba820..ac8386a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 | @@ -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 @@ -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 | diff --git a/src/__tests__/check.test.ts b/src/__tests__/check.test.ts index 21edd73..2fa7861 100644 --- a/src/__tests__/check.test.ts +++ b/src/__tests__/check.test.ts @@ -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(); diff --git a/src/__tests__/email-provider.test.ts b/src/__tests__/email-provider.test.ts index 4b14c04..dbcc75b 100644 --- a/src/__tests__/email-provider.test.ts +++ b/src/__tests__/email-provider.test.ts @@ -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', () => { @@ -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: '
Hi
' }, - ]; - - 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: 'Hi
' }, - ]; - - 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: 'Hi
' }, - ]; - - 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: 'Hi
', - 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'); - }); }); diff --git a/src/__tests__/init-workflows.test.ts b/src/__tests__/init-workflows.test.ts index b3be2b1..8ce3eb9 100644 --- a/src/__tests__/init-workflows.test.ts +++ b/src/__tests__/init-workflows.test.ts @@ -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'); @@ -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'); diff --git a/src/__tests__/init.test.ts b/src/__tests__/init.test.ts index 159ae79..ce5e820 100644 --- a/src/__tests__/init.test.ts +++ b/src/__tests__/init.test.ts @@ -29,7 +29,7 @@ describe('parseInitFlags', () => { '--voice', 'Casual', '--llm', 'openai', '--subscriber-store', 'supabase', - '--email-provider', 'gmail', + '--email-provider', 'resend', '--from-email', 'hi@acme.dev', '--yes', '--pipeline', 'weekly', @@ -40,7 +40,7 @@ describe('parseInitFlags', () => { voice: 'Casual', llm: 'openai', subscriberStore: 'supabase', - emailProvider: 'gmail', + emailProvider: 'resend', fromEmail: 'hi@acme.dev', yes: true, pipeline: 'weekly', @@ -124,13 +124,6 @@ describe('buildEnvContent', () => { 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, @@ -475,34 +468,6 @@ describe('main (init)', () => { 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); diff --git a/src/auth.ts b/src/auth.ts index fd6104d..a0776b1 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -1,13 +1,7 @@ import { createServer } from 'http'; -import { readFileSync, writeFileSync, existsSync } from 'fs'; -import { join } from 'path'; import { exec } from 'child_process'; -import { OAuth2Client } from 'google-auth-library'; -import { GMAIL_CLIENT_ID, GMAIL_CLIENT_SECRET } from './gmail-oauth.js'; import { saveLinkedInCredentials, getCredentialsPath } from './social/credentials.js'; -const GMAIL_SCOPE = 'https://www.googleapis.com/auth/gmail.send'; - const LINKEDIN_AUTH_URL = 'https://www.linkedin.com/oauth/v2/authorization'; const LINKEDIN_TOKEN_URL = 'https://www.linkedin.com/oauth/v2/accessToken'; const LINKEDIN_SCOPES = 'openid profile w_member_social'; @@ -22,113 +16,6 @@ function openBrowser(url: string): void { exec(`${cmd} "${url}"`); } -function updateEnvFile(key: string, value: string): void { - const envPath = join(process.cwd(), '.env'); - if (!existsSync(envPath)) { - writeFileSync(envPath, `${key}=${value}\n`, 'utf-8'); - return; - } - - const content = readFileSync(envPath, 'utf-8'); - const regex = new RegExp(`^${key}=.*$`, 'm'); - - if (regex.test(content)) { - writeFileSync(envPath, content.replace(regex, `${key}=${value}`), 'utf-8'); - } else { - const suffix = content.endsWith('\n') ? '' : '\n'; - writeFileSync(envPath, content + suffix + `${key}=${value}\n`, 'utf-8'); - } -} - -export async function authGmail(): PromiseYou can close this window.
'); - server.close(); - reject(new Error(`OAuth error: ${error}`)); - return; - } - - if (!code) { - res.writeHead(400, { 'Content-Type': 'text/html' }); - res.end('No refresh token received. Try revoking access at myaccount.google.com/permissions and re-running this command.
'); - server.close(); - reject(new Error('No refresh token received. Revoke access and try again.')); - return; - } - - // Save to .env - updateEnvFile('GMAIL_REFRESH_TOKEN', tokens.refresh_token); - updateEnvFile('EMAIL_PROVIDER', 'gmail'); - - res.writeHead(200, { 'Content-Type': 'text/html' }); - res.end('Refresh token saved to .env. You can close this window.
'); - - console.log(''); - console.log(' Gmail authorized successfully!'); - console.log(' GMAIL_REFRESH_TOKEN saved to .env'); - console.log(' EMAIL_PROVIDER set to gmail'); - console.log(''); - - server.close(); - resolve(); - } catch (err) { - res.writeHead(500, { 'Content-Type': 'text/html' }); - res.end('Something went wrong.
'); - server.close(); - reject(err); - } - }); - - server.listen(0, () => { - const port = (server.address() as { port: number }).port; - const redirectUri = `http://localhost:${port}/callback`; - const authClient = new OAuth2Client(GMAIL_CLIENT_ID, GMAIL_CLIENT_SECRET, redirectUri); - - const authUrl = authClient.generateAuthUrl({ - access_type: 'offline', - prompt: 'consent', - scope: [GMAIL_SCOPE], - }); - - console.log(''); - console.log(' Opening browser for Gmail authorization...'); - console.log(` If it doesn't open, visit: ${authUrl}`); - console.log(''); - - openBrowser(authUrl); - }); - - server.on('error', reject); - }); -} - interface LinkedInTokenResponse { access_token: string; expires_in: number; @@ -292,27 +179,20 @@ export async function main(): Promise