@stacksjs/cloud 0.75.109 → 0.75.110
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/dist/cloud/index.js +1 -0
- package/dist/edge/origin-request.d.ts +24 -0
- package/dist/edge/origin-request.js +1 -0
- package/dist/helpers.js +1 -0
- package/dist/imap/imap-server.d.ts +100 -0
- package/dist/imap/imap-server.js +23 -0
- package/dist/imap/smtp-server.js +4 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +1 -5
- package/dist/types.js +0 -0
- package/package.json +13 -13
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{config}from"@stacksjs/config";import{InfrastructureGenerator}from"@stacksjs/ts-cloud";export class Cloud{generator;cloudConfig;props;constructor(cloudConfig,props){this.cloudConfig=cloudConfig;this.props=props;const environment=(props.appEnv==="local"?"development":props.appEnv)||"production";this.generator=new InfrastructureGenerator({config:this.cloudConfig,environment})}generate(){return this.generator.generate().toJSON()}shouldDeployApi(){return config.cloud.api?.deploy??!1}}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export declare function handler(event: CloudFrontOriginRequestEvent, _context: unknown, callback: CloudFrontCallback): void;
|
|
2
|
+
/**
|
|
3
|
+
* The slice of a CloudFront origin-request event this handler touches.
|
|
4
|
+
*
|
|
5
|
+
* Lambda@Edge hands the function an event, a context and a callback, and all
|
|
6
|
+
* three were `any` behind a `TODO: narrow types here`. Only `uri` is read and
|
|
7
|
+
* only `uri` is written, so that is what is described - a handler this small
|
|
8
|
+
* does not need the whole AWS event surface to be checked.
|
|
9
|
+
*/
|
|
10
|
+
declare interface CloudFrontRequest {
|
|
11
|
+
uri: string
|
|
12
|
+
method?: string
|
|
13
|
+
querystring?: string
|
|
14
|
+
headers?: Record<string, Array<{ key?: string, value: string }>>
|
|
15
|
+
}
|
|
16
|
+
declare interface CloudFrontResponse {
|
|
17
|
+
status: string
|
|
18
|
+
statusDescription?: string
|
|
19
|
+
headers?: Record<string, Array<{ key?: string, value: string }>>
|
|
20
|
+
}
|
|
21
|
+
declare interface CloudFrontOriginRequestEvent {
|
|
22
|
+
Records: Array<{ cf: { request: CloudFrontRequest } }>
|
|
23
|
+
}
|
|
24
|
+
declare type CloudFrontCallback = (_error: Error | null, _result?: CloudFrontRequest | CloudFrontResponse) => void;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
const config={suffix:".html",removeTrailingSlash:!1},regexSuffixless=/\/[^/.]+$/,regexTrailingSlash=/.+\/$/;export function handler(event,_context,callback){const record=event.Records[0];if(!record){callback(Error("CloudFront origin-request event carried no records"));return}const{request}=record.cf,{uri}=request,{suffix}=config;if(uri==="/"){request.uri="/index.html";callback(null,request);return}if(uri.match(regexSuffixless)){request.uri=uri+suffix;callback(null,request);return}if(uri.match(regexTrailingSlash)){request.uri=`${uri.slice(0,-1)}.html`;callback(null,request);return}callback(null,request)}
|
package/dist/helpers.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{existsSync}from"node:fs";import process from"node:process";import{AWSClient,AWSCloudFormationClient as CloudFormationClient,CloudWatchLogsClient,EC2Client,IAMClient,LambdaClient,Route53DomainsClient,S3Client,SSMClient}from"@stacksjs/ts-cloud";import{config}from"@stacksjs/config";import{err,handleError,ok}from"@stacksjs/error-handling";import{log}from"@stacksjs/logging";import{path as p}from"@stacksjs/path";import{slug}from"@stacksjs/strings";export async function loadCloudStackConfig(){const file=p.projectPath("config/cloud.ts");if(!existsSync(file))return{};return(await import(file)).tsCloud??{}}export function cloudEnvironment(){return process.env.APP_ENV||process.env.NODE_ENV||"production"}export async function stacksCloudName(cloud){const{project}=cloud??await loadCloudStackConfig();return`${project?.name||"stacks"}-cloud`}export async function stacksCloudRegion(cloud,environment=cloudEnvironment()){const{project,environments}=cloud??await loadCloudStackConfig();return process.env.AWS_REGION||environments?.[environment]?.region||project?.region||"us-east-1"}async function ec2Request(region,action,params={}){const client=new AWSClient,queryParams={Action:action,Version:"2016-11-15",...params};return await client.request({service:"ec2",region,method:"POST",path:"/",queryParams})}async function cwlRequest(region,action,payload){return await new AWSClient().request({service:"logs",region,method:"POST",path:"/",headers:{"Content-Type":"application/x-amz-json-1.1","X-Amz-Target":`Logs_20140328.${action}`},body:JSON.stringify(payload)})}export async function getSecurityGroupId(securityGroupName){const ec2=new EC2Client(await stacksCloudRegion()),{SecurityGroups}=await ec2.describeSecurityGroups({Filters:[{Name:"group-name",Values:[securityGroupName]}]});if(!SecurityGroups)return err(`Security group ${securityGroupName} not found`);if(SecurityGroups[0])return ok(SecurityGroups[0].GroupId);return err(`Security group ${securityGroupName} not found`)}export function purchaseOptionsFromContactInfo(c){const admin=c.admin??{},tech=c.tech??{},privacy=c.privacy??!0,contactType=String(c.contactType||"PERSON").toUpperCase();return{domain:"",years:1,privacy,autoRenew:!0,registrantFirstName:c.firstName??"",registrantLastName:c.lastName??"",registrantOrganization:c.organizationName??"",registrantAddressLine1:c.addressLine1??"",registrantAddressLine2:c.addressLine2??"",registrantCity:c.city??"",registrantState:c.state??"",registrantCountry:c.countryCode,registrantZip:c.zip??"",registrantPhone:c.phoneNumber??"",registrantEmail:c.email??"",adminFirstName:admin.firstName||c.firstName||"",adminLastName:admin.lastName||c.lastName||"",adminOrganization:admin.organizationName||c.organizationName||"",adminAddressLine1:admin.addressLine1||c.addressLine1||"",adminAddressLine2:admin.addressLine2||c.addressLine2||"",adminCity:admin.city||c.city||"",adminState:admin.state||c.state||"",adminCountry:admin.countryCode||c.countryCode,adminZip:admin.zip||c.zip||"",adminPhone:admin.phoneNumber||c.phoneNumber||"",adminEmail:admin.email||c.email||"",techFirstName:tech.firstName||c.firstName||"",techLastName:tech.lastName||c.lastName||"",techOrganization:tech.organizationName||c.organizationName||"",techAddressLine1:tech.addressLine1||c.addressLine1||"",techAddressLine2:tech.addressLine2||c.addressLine2||"",techCity:tech.city||c.city||"",techState:tech.state||c.state||"",techCountry:tech.countryCode||c.countryCode,techZip:tech.zip||c.zip||"",techPhone:tech.phoneNumber||c.phoneNumber||"",techEmail:tech.email||c.email||"",privacyAdmin:c.privacyAdmin??privacy,privacyTech:c.privacyTech??privacy,privacyRegistrant:c.privacyRegistrant??privacy,contactType,verbose:!1}}const REGISTRANT_FLAGS={firstName:"registrantFirstName",lastName:"registrantLastName",organization:"registrantOrganization",addressLine1:"registrantAddressLine1",addressLine2:"registrantAddressLine2",city:"registrantCity",state:"registrantState",country:"registrantCountry",zip:"registrantZip",phone:"registrantPhone",email:"registrantEmail"};export function applyPurchaseFlags(base,flags){const options={...base};for(const[flag,value]of Object.entries(flags)){if(value===void 0||value===null||value==="")continue;options[REGISTRANT_FLAGS[flag]??flag]=value}for(const key of["privacy","autoRenew","privacyAdmin","privacyTech","privacyRegistrant","verbose"])if(options[key]==="false")options[key]=!1;else if(options[key]==="true")options[key]=!0;if(flags.privacy!==void 0){for(const key of["privacyAdmin","privacyTech","privacyRegistrant"])if(flags[key]===void 0)options[key]=options.privacy}const years=Number(options.years);options.years=Number.isInteger(years)&&years>0?years:1;if(typeof options.contactType==="string")options.contactType=options.contactType.toUpperCase();return options}export async function purchaseDomain(domain,options,registrar=new Route53DomainsClient){const contactType=String(options.contactType||"PERSON").toUpperCase(),formatPhone=(phone)=>phone.toString().includes("+")?phone.toString():`+${phone.toString()}`,contact=(prefix)=>({FirstName:options[`${prefix}FirstName`],LastName:options[`${prefix}LastName`],ContactType:contactType,OrganizationName:options[`${prefix}Organization`],AddressLine1:options[`${prefix}AddressLine1`],AddressLine2:options[`${prefix}AddressLine2`],City:options[`${prefix}City`],State:options[`${prefix}State`],CountryCode:options[`${prefix}Country`],ZipCode:String(options[`${prefix}Zip`]??""),PhoneNumber:formatPhone(options[`${prefix}Phone`]??""),Email:options[`${prefix}Email`]});try{const response=await registrar.registerDomain({DomainName:domain,DurationInYears:options.years||1,AutoRenew:options.autoRenew??!0,AdminContact:contact("admin"),RegistrantContact:contact("registrant"),TechContact:contact("tech"),PrivacyProtectAdminContact:options.privacyAdmin??options.privacy??!0,PrivacyProtectRegistrantContact:options.privacyRegistrant??options.privacy??!0,PrivacyProtectTechContact:options.privacyTech??options.privacy??!0});if(!response?.OperationId)return err(Error(`Route 53 returned no operation id for ${domain}, so the registration cannot be confirmed.`));return ok({OperationId:response.OperationId})}catch(error){return err(error instanceof Error?error:Error(String(error)))}}export const JUMP_BOX_OUTPUT="JumpBoxInstanceId";const JUMP_BOX_CONFIG_HINT="Set `infrastructure.jumpBox: true` in the `tsCloud` export of config/cloud.ts (or an object: `{ size, keyName, allowedCidrs, databaseTools, mountEfs }`), then run `buddy deploy`.";export function declaresJumpBox(cloud){const jumpBox=cloud.infrastructure?.jumpBox;if(!jumpBox)return!1;return jumpBox===!0||jumpBox.enabled!==!1}export async function getJumpBoxState(options={}){const cloud=options.config??await loadCloudStackConfig(),stack=await stacksCloudName(cloud),region=await stacksCloudRegion(cloud),cloudFormation=(options.cloudFormation??((r)=>new CloudFormationClient(r)))(region),state={stack,region,declared:declaresJumpBox(cloud),deployed:!1};try{const{Stacks}=await cloudFormation.describeStacks({stackName:stack}),deployed=Stacks?.[0];if(!deployed||deployed.StackStatus==="DELETE_COMPLETE")return ok(state);state.deployed=!0;state.instanceId=deployed.Outputs?.find((output)=>output.OutputKey===JUMP_BOX_OUTPUT)?.OutputValue||void 0;return ok(state)}catch(error){const e=error instanceof Error?error:Error(String(error));if(/does not exist/i.test(e.message))return ok(state);return err(e)}}export async function getJumpBoxInstanceId(options={}){const state=await getJumpBoxState(options);if(state.isErr)throw state.error;return state.value.instanceId}function report(done,state,message){return{done,state,message}}function where(state){return`the ${state.stack} stack (${state.region})`}export async function addJumpBox(options={}){const result=await getJumpBoxState(options);if(result.isErr)return err(`Could not read the jump box from the deployed stack: ${result.error.message}`);const state=result.value;if(state.instanceId)return ok(report(!0,state,`${where(state)} already has a jump box, ${state.instanceId}. Connect with \`buddy cloud --ssh\`.`));if(state.declared){const missing=state.deployed?"has not been deployed with it yet":"is not deployed";return ok(report(!1,state,`config/cloud.ts declares a jump box, but ${where(state)} ${missing}. Run \`buddy deploy\` to create it.`))}return ok(report(!1,state,`${where(state)} has no jump box. ${JUMP_BOX_CONFIG_HINT}`))}export async function deleteJumpBox(options={}){const result=await getJumpBoxState(options);if(result.isErr)return err(`Could not read the jump box from the deployed stack: ${result.error.message}`);const state=result.value;if(!state.instanceId){const pending=state.declared?" config/cloud.ts still declares one, so the next `buddy deploy` creates it; remove `infrastructure.jumpBox` to keep it gone.":"";return ok(report(!0,state,`${where(state)} has no jump box.${pending}`))}if(state.declared)return ok(report(!1,state,`${where(state)} has a jump box, ${state.instanceId}. Remove \`infrastructure.jumpBox\` from the \`tsCloud\` export of config/cloud.ts (or set \`enabled: false\`), then run \`buddy deploy\`; CloudFormation deletes the instance, its security group and its role.`));return ok(report(!1,state,`${where(state)} still has a jump box, ${state.instanceId}, though config/cloud.ts no longer declares one. Run \`buddy deploy\` to remove it.`))}export async function deleteIamUsers(){const iam=new IAMClient("us-east-1"),data=await iam.listUsers(),teamName=slug(config.team.name),users=data.Users?.filter((user)=>{const userNameLower=user.UserName?.toLowerCase();return userNameLower!=="stacks"&&userNameLower!==teamName.toLowerCase()&&userNameLower?.includes(teamName.toLowerCase())})||[];if(!users||users.length===0)return ok(`No Stacks IAM users found for team ${teamName}`);const promises=users.map(async(user)=>{const userName=user.UserName||"";log.info(`Deleting IAM user: ${userName}`);const policies=await iam.listAttachedUserPolicies({UserName:userName});await Promise.all(policies.AttachedPolicies?.map((policy)=>{const p=policy;return iam.detachUserPolicy({UserName:userName,PolicyArn:p.PolicyArn||""})})||[]);const accessKeys=await iam.listAccessKeys({UserName:userName});await Promise.all(accessKeys.AccessKeyMetadata?.map((key)=>{const k=key;return iam.deleteAccessKey({UserName:userName,AccessKeyId:k.AccessKeyId||""})})||[]);return iam.deleteUser({UserName:userName})});try{await Promise.all(promises)}catch(error){console.error(error);return err(handleError("Error deleting Stacks IAM users").message)}return ok(`Stacks IAM users deleted for team ${teamName}`)}export async function deleteStacksBuckets(options={}){try{const s3=(options.client??((r)=>new S3Client(r)))(await stacksCloudRegion(options.config)),stacksBuckets=(await s3.listBuckets()).Buckets?.filter((bucket)=>bucket.Name?.includes("stacks"));if(!stacksBuckets)return err("No stacks buckets found");const promises=stacksBuckets.map(async(bucket)=>{const bucketName=bucket.Name||"";log.info(`Deleting bucket ${bucketName}...`);let continuationToken,hasMoreObjects=!0;while(hasMoreObjects){const objects=await s3.listObjects({bucket:bucketName,continuationToken});if(objects.objects&&objects.objects.length>0){log.info(`Deleting ${objects.objects.length} objects from bucket ${bucketName}...`);await Promise.all(objects.objects.map((object)=>{const o=object;return s3.deleteObject(bucketName,o.Key||"")})).catch((error)=>{log.error(`Failed to delete objects from bucket ${bucketName}:`,error);throw error})}hasMoreObjects=!!objects.nextContinuationToken;continuationToken=objects.nextContinuationToken}log.info(`Finished deleting objects from bucket ${bucketName}`);log.info(`Deleting bucket ${bucketName} versions...`);try{let keyMarker,versionIdMarker,hasMore=!0;while(hasMore){const versions=await s3.listObjectVersions({bucket:bucketName,keyMarker,versionIdMarker});if(versions.versions&&versions.versions.length>0){await Promise.all(versions.versions.map((version)=>{const v=version;return s3.deleteObject(bucketName,v.Key||"")})).catch((error)=>handleError(error));log.info(`Deleted ${versions.versions.length} versions from bucket ${bucketName}`)}if(versions.deleteMarkers&&versions.deleteMarkers.length>0){await Promise.all(versions.deleteMarkers.map((marker)=>{const m=marker;return s3.deleteObject(bucketName,m.Key||"")})).catch((error)=>handleError(error));log.info(`Deleted ${versions.deleteMarkers.length} delete markers from bucket ${bucketName}`)}hasMore=!!versions.nextKeyMarker;keyMarker=versions.nextKeyMarker;versionIdMarker=versions.nextVersionIdMarker}log.info(`Finished deleting all versions from bucket ${bucketName}`);const uploads=await s3.listMultipartUploads(bucketName);if(uploads&&uploads.length>0){log.info("Aborting bucket multipart uploads...");await Promise.all(uploads.map((upload)=>{const u=upload;return s3.abortMultipartUpload(bucketName,u.Key||"",u.UploadId||"")})).catch((error)=>handleError(error));log.info(`Finished aborting multipart uploads from bucket ${bucketName}`)}await s3.deleteBucket(bucketName).catch((error)=>handleError(error));log.info(`Bucket ${bucketName} deleted`)}catch(error){log.info(`Error listing bucket ${bucketName} versions`,error)}});await Promise.all(promises).catch((error)=>{console.error(error);return err(handleError("Error deleting stacks buckets"))});return ok("Stacks buckets deleted")}catch(error){return err(handleError("Error deleting stacks buckets",error))}}export async function deleteStacksFunctions(options={}){const client=options.client??((r)=>new LambdaClient(r)),regions=[...new Set([await stacksCloudRegion(options.config),"us-east-1"])],targets=[];for(const region of regions){const lambda=client(region),data=await lambda.listFunctions();for(const func of data.Functions??[]){const name=func.FunctionName;if(name?.includes("stacks"))targets.push({lambda,name})}}if(targets.length===0)return ok("No stacks functions found");try{await Promise.all(targets.map(({lambda,name})=>lambda.deleteFunction(name)))}catch(error){const e=error;if(e.message.includes("it is a replicated function")){log.info("Function is replicated, skipping...");return ok("CloudFront is still deleting the some functions. Try again later.")}return err(handleError("Error deleting stacks functions",e).message)}return ok("Stacks functions deleted")}export async function deleteLogGroups(){try{const regionsResult=await ec2Request(await stacksCloudRegion(),"DescribeRegions"),regionItems=regionsResult.regionInfo?.item??regionsResult.Regions??regionsResult.regionSet??[],regions=[];if(Array.isArray(regionItems))for(const r of regionItems){const name=r.regionName??r.RegionName;if(name)regions.push(name)}for(const region of regions){const logGroups=await new CloudWatchLogsClient(region).describeLogGroups();if(logGroups?.logGroups)for(const group of logGroups.logGroups){const appName=config.app.name?.toLocaleLowerCase()||"stacks";if(group.logGroupName?.includes(appName))await cwlRequest(region,"DeleteLogGroup",{logGroupName:group.logGroupName})}}return ok("Log groups deleted in all regions")}catch(error){return err(handleError("Error deleting log groups",error))}}export async function deleteParameterStore(options={}){const ssm=(options.client??((r)=>new SSMClient(r)))(await stacksCloudRegion(options.config)),data=await ssm.describeParameters();if(!data.Parameters)return ok("No parameters found");const appName=config.app.name?.toLocaleLowerCase()||"stacks",stacksParameters=data.Parameters.filter((param)=>{return param.Name?.includes(appName)})||[];if(!stacksParameters||stacksParameters.length===0)return ok("No stacks parameters found");const promises=stacksParameters.map((param)=>{const p=param;return ssm.deleteParameter({Name:p.Name||""})});try{await Promise.all(promises)}catch(error){return err(handleError("Error deleting parameter store",error).message)}return ok("Parameter store deleted")}export async function deleteVpcs(options={}){const region=await stacksCloudRegion(options.config),ec2=(options.client??((r)=>new EC2Client(r)))(region),request=options.request??ec2Request,vpcNamePattern=config.app.name?`${config.app.name.toLowerCase()}-`:"stacks-";try{const{Vpcs}=await ec2.describeVpcs();if(!Vpcs||Vpcs.length===0)return ok("No VPCs found");const vpcsToDel=Vpcs.filter((vpc)=>{return vpc.Tags?.some((tag)=>tag.Key==="Name"&&tag.Value===vpcNamePattern)});if(vpcsToDel.length===0)return ok(`No VPCs found matching the pattern: ${vpcNamePattern}`);for(const vpc of vpcsToDel)if(vpc.VpcId){await request(region,"DeleteVpc",{VpcId:vpc.VpcId});log.info(`Deleted VPC: ${vpc.VpcId} (${vpcNamePattern})`)}return ok(`Deleted ${vpcsToDel.length} VPCs matching the pattern: ${vpcNamePattern}`)}catch(error){return err(handleError(`Error deleting VPCs: ${error}`))}}export async function deleteCdkRemnants(){try{await Bun.$`rm -rf ${p.cloudPath("cdk.out/")} ${p.cloudPath("cdk.context.json")}`.text();return ok("CDK remnants deleted")}catch(error){return err(handleError("Error deleting CDK remnants",error))}}export async function deleteSubnets(options={}){const region=await stacksCloudRegion(options.config),ec2=(options.client??((r)=>new EC2Client(r)))(region),request=options.request??ec2Request,subnetNamePattern=config.app.name?`${config.app.name.toLowerCase()}-`:"stacks-";try{const{Subnets}=await ec2.describeSubnets();if(!Subnets||Subnets.length===0)return ok("No subnets found");const subnetsToDel=Subnets.filter((subnet)=>{return subnet.Tags?.some((tag)=>tag.Key==="Name"&&tag.Value?.startsWith(subnetNamePattern))});if(subnetsToDel.length===0)return ok(`No subnets found matching the pattern: ${subnetNamePattern}`);for(const subnet of subnetsToDel)if(subnet.SubnetId){const networkInterfaces=(await request(region,"DescribeNetworkInterfaces",{"Filter.1.Name":"subnet-id","Filter.1.Value.1":subnet.SubnetId})).networkInterfaceSet?.item??[];for(const ni of Array.isArray(networkInterfaces)?networkInterfaces:[]){const niId=ni.networkInterfaceId;if(niId){const attachment=ni.attachment;if(attachment?.instanceId){await ec2.terminateInstances([attachment.instanceId]);log.info(`Terminated instance: ${attachment.instanceId}`);await new Promise((resolve)=>setTimeout(resolve,60000))}if(attachment?.attachmentId){await request(region,"DetachNetworkInterface",{AttachmentId:attachment.attachmentId,Force:"true"});log.info(`Detached network interface: ${niId}`);await new Promise((resolve)=>setTimeout(resolve,1e4))}await request(region,"DeleteNetworkInterface",{NetworkInterfaceId:niId});log.info(`Deleted network interface: ${niId}`)}}await request(region,"DeleteSubnet",{SubnetId:subnet.SubnetId});log.info(`Deleted subnet: ${subnet.SubnetId} (${subnet.Tags?.find((tag)=>tag.Key==="Name")?.Value})`)}return ok(`Deleted ${subnetsToDel.length} subnets matching the pattern: ${subnetNamePattern}`)}catch(error){return err(handleError(`Error deleting subnets: ${error}`))}}export async function hasBeenDeployed(options={}){const s3=(options.client??((r)=>new S3Client(r)))(await stacksCloudRegion(options.config));try{const response=await s3.listBuckets();return ok(response.Buckets?.some((bucket)=>bucket.Name?.includes(config.app.name?.toLocaleLowerCase()||"stacks"))||!1)}catch(error){console.error(error);return err(handleError("Error checking if the app has been deployed"))}}export async function isFirstDeployment(){const cloud=await loadCloudStackConfig(),stackName=await stacksCloudName(cloud);return!(await new CloudFormationClient(await stacksCloudRegion(cloud)).listStacks(["CREATE_COMPLETE","UPDATE_COMPLETE"])).StackSummaries?.some((stack)=>{return stack.StackName===stackName})}export async function isFailedState(){const cloud=await loadCloudStackConfig(),stackName=await stacksCloudName(cloud),data=await new CloudFormationClient(await stacksCloudRegion(cloud)).listStacks(["CREATE_FAILED","UPDATE_FAILED","ROLLBACK_COMPLETE","UPDATE_ROLLBACK_COMPLETE"]);return Boolean(data.StackSummaries?.some((stack)=>stack.StackName===stackName))}export async function diffStackTemplate(localTemplate,options={}){const cloud=options.config??await loadCloudStackConfig(),stack=await stacksCloudName(cloud),region=await stacksCloudRegion(cloud),cloudFormation=(options.client??((r)=>new CloudFormationClient(r)))(region);let deployedTemplate;try{deployedTemplate=(await cloudFormation.getTemplate(stack)).TemplateBody}catch(error){const e=error instanceof Error?error:Error(String(error));if(!/does not exist/i.test(e.message))return err(e)}const deployed=typeof deployedTemplate==="string";let changed=!0;if(deployed)try{changed=!Bun.deepEquals(JSON.parse(deployedTemplate),JSON.parse(localTemplate))}catch{changed=deployedTemplate!==localTemplate}return ok({stack,region,deployed,changed,deployedBytes:deployed?Buffer.byteLength(deployedTemplate):0,localBytes:Buffer.byteLength(localTemplate)})}export async function getOrCreateTimestamp(options={}){const parameterName="/stacks/timestamp",ssm=(options.client??((r)=>new SSMClient(r)))(await stacksCloudRegion(options.config));try{const response=await ssm.getParameter({Name:parameterName}),timestamp=response.Parameter?response.Parameter.Value:void 0;if(!timestamp)throw Error("Timestamp parameter not found");return timestamp}catch(error){const timestamp=new Date().getTime().toString();log.debug(`Creating timestamp parameter ${parameterName} with value ${timestamp}`,error);await ssm.putParameter({Name:parameterName,Value:timestamp,Type:"String"});return timestamp}}export async function getCloudFrontDistributionId(){return""}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import * as tls from 'node:tls';
|
|
2
|
+
/**
|
|
3
|
+
* Categorize an email based on sender patterns and headers
|
|
4
|
+
* @param from - The From header value
|
|
5
|
+
* @param headers - All email headers
|
|
6
|
+
* @param customPatterns - Optional custom patterns from config
|
|
7
|
+
* @returns The category for the email
|
|
8
|
+
*/
|
|
9
|
+
export declare function categorizeEmail(from: string, headers: Record<string, string>, customPatterns?: CategorizationConfig): EmailCategory;
|
|
10
|
+
/**
|
|
11
|
+
* Create and start an IMAP server
|
|
12
|
+
*/
|
|
13
|
+
export declare function startImapServer(config: ImapServerConfig): Promise<ImapServer>;
|
|
14
|
+
/**
|
|
15
|
+
* Known sender patterns for automatic email categorization
|
|
16
|
+
* These patterns match common email senders to categorize emails like Gmail does
|
|
17
|
+
* @defaultValue
|
|
18
|
+
* ```ts
|
|
19
|
+
* {
|
|
20
|
+
* social: {
|
|
21
|
+
* domains: [ 'facebookmail.com', 'facebook.com', 'fb.com', 'twitter.com', 'x.com', 'linkedin.com', 'linkedinmail.com', 'instagram.com', 'tiktok.com', 'snapchat.com', 'pinterest.com', 'reddit.com', 'redditmail.com', 'tumblr.com', 'discord.com', 'discordapp.com', 'slack.com', 'whatsapp.com', 'telegram.org', 'signal.org', 'nextdoor.com', 'meetup.com', 'bumble.com', 'tinder.com', 'hinge.co', 'strava.com', 'twitch.tv', 'mastodon.social', 'threads.net', 'bluesky.social', ],
|
|
22
|
+
* substrings: [ 'notification', 'noreply', 'no-reply', '@social.', '@notify.', ],
|
|
23
|
+
* headers: { 'x-mailer': ['facebook', 'twitter', 'linkedin'] }
|
|
24
|
+
* },
|
|
25
|
+
* forums: {
|
|
26
|
+
* domains: [ 'googlegroups.com', 'groups.google.com', 'groups.io', 'yahoogroups.com', 'discourse.org', 'mailman.', 'lists.', 'freelists.org', 'topica.com', 'listserv.', 'gaggle.email', 'simplelists.com', 'mailchimp.com', 'stackexchange.com', 'stackoverflow.com', 'quora.com', 'dev.to', 'hackernews.com', 'lobste.rs', 'producthunt.com', ],
|
|
27
|
+
* substrings: [ '-list@', '-users@', '-dev@', '-announce@', 'mailing-list', 'mailinglist', 'discussion@', 'forum@', 'community@', '@lists.', '@list.', ],
|
|
28
|
+
* headers: {
|
|
29
|
+
* 'list-unsubscribe': [''],
|
|
30
|
+
* 'list-id': [''],
|
|
31
|
+
* 'x-mailing-list': [''],
|
|
32
|
+
* 'precedence': ['list', 'bulk']
|
|
33
|
+
* }
|
|
34
|
+
* },
|
|
35
|
+
* updates: {
|
|
36
|
+
* domains: [ 'github.com', 'gitlab.com', 'bitbucket.org', 'circleci.com', 'travis-ci.com', 'vercel.com', 'netlify.com', 'heroku.com', 'aws.amazon.com', 'amazonses.com', 'cloud.google.com', 'azure.microsoft.com', 'digitalocean.com', 'linode.com', 'sentry.io', 'bugsnag.com', 'datadog.com', 'newrelic.com', 'pagerduty.com', 'opsgenie.com', 'atlassian.com', 'jira.com', 'notion.so', 'clickup.com', 'asana.com', 'linear.app', 'paypal.com', 'venmo.com', 'cashapp.com', 'stripe.com', 'squareup.com', 'intuit.com', 'quickbooks.com', 'chase.com', 'wellsfargo.com', 'bankofamerica.com', 'capitalone.com', 'citi.com', 'discover.com', 'americanexpress.com', 'amex.com', 'ups.com', 'fedex.com', 'usps.com', 'dhl.com', 'amazon.com', 'booking.com', 'airbnb.com', 'vrbo.com', 'expedia.com', 'kayak.com', 'tripadvisor.com', 'uber.com', 'lyft.com', 'delta.com', 'united.com', 'aa.com', 'southwest.com', 'apple.com', 'google.com', 'microsoft.com', 'dropbox.com', 'box.com', 'zoom.us', 'calendly.com', 'docusign.com', 'hellosign.com', ],
|
|
37
|
+
* substrings: [ 'alert@', 'alerts@', 'notification@', 'notifications@', 'update@', 'updates@', 'status@', 'confirm@', 'confirmation@', 'receipt@', 'invoice@', 'billing@', 'payment@', 'security@', 'account@', 'verify@', 'shipping@', 'delivery@', 'tracking@', 'support@', 'help@', 'service@', 'donotreply@', 'do-not-reply@', 'do_not_reply@', '@mail.', '@email.', '@e.', '@em.', ],
|
|
38
|
+
* headers: {
|
|
39
|
+
* 'x-auto-response-suppress': [''],
|
|
40
|
+
* 'auto-submitted': ['auto-generated', 'auto-replied']
|
|
41
|
+
* }
|
|
42
|
+
* },
|
|
43
|
+
* promotions: {
|
|
44
|
+
* domains: [ 'mailchimp.com', 'sendgrid.net', 'sendgrid.com', 'constantcontact.com', 'mailgun.org', 'mailgun.com', 'mailjet.com', 'sendinblue.com', 'brevo.com', 'hubspot.com', 'hubspotemail.net', 'klaviyo.com', 'omnisend.com', 'activecampaign.com', 'drip.com', 'convertkit.com', 'aweber.com', 'getresponse.com', 'campaignmonitor.com', 'createsend.com', 'sailthru.com', 'iterable.com', 'marketo.com', 'eloqua.com', 'pardot.com', 'amazonses.com', 'amazon.com', 'ebay.com', 'etsy.com', 'walmart.com', 'target.com', 'bestbuy.com', 'macys.com', 'nordstrom.com', 'kohls.com', 'nike.com', 'adidas.com', 'gap.com', 'oldnavy.com', 'zara.com', 'hm.com', 'sephora.com', 'ulta.com', 'wayfair.com', 'overstock.com', 'homedepot.com', 'lowes.com', 'costco.com', 'samsclub.com', 'doordash.com', 'grubhub.com', 'ubereats.com', 'postmates.com', 'instacart.com', 'dominos.com', 'pizzahut.com', 'papajohns.com', 'starbucks.com', 'dunkindonuts.com', 'chipotle.com', 'netflix.com', 'hulu.com', 'disneyplus.com', 'hbomax.com', 'peacocktv.com', 'paramountplus.com', 'spotify.com', 'apple.com', 'audible.com', 'kindle.com', 'ticketmaster.com', 'stubhub.com', 'eventbrite.com', 'groupon.com', 'livingsocial.com', 'retailmenot.com', 'slickdeals.net', 'honey.com', 'rakuten.com', ],
|
|
45
|
+
* substrings: [ 'promo@', 'promotions@', 'marketing@', 'deals@', 'offers@', 'sale@', 'sales@', 'newsletter@', 'news@', 'digest@', 'shop@', 'store@', 'order@', 'orders@', 'rewards@', 'loyalty@', 'members@', '@promo.', '@marketing.', '@deals.', '@offers.', '@shop.', '@store.', 'unsubscribe', ],
|
|
46
|
+
* headers: { 'x-campaign': [''], 'x-mailchimp-': [''], 'x-sg-': [''], 'x-ses-': [''] }
|
|
47
|
+
* }
|
|
48
|
+
* }
|
|
49
|
+
* ```
|
|
50
|
+
*/
|
|
51
|
+
export declare const CATEGORY_PATTERNS: Record<Exclude<EmailCategory, 'primary'>, {
|
|
52
|
+
domains: string[]
|
|
53
|
+
substrings: string[]
|
|
54
|
+
headers?: Record<string, string[]>
|
|
55
|
+
}>;
|
|
56
|
+
/**
|
|
57
|
+
* Pattern configuration for a single email category
|
|
58
|
+
*/
|
|
59
|
+
export declare interface CategoryPatternConfig {
|
|
60
|
+
domains?: string[]
|
|
61
|
+
substrings?: string[]
|
|
62
|
+
headers?: Record<string, string[]>
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Configuration for all email categories
|
|
66
|
+
*/
|
|
67
|
+
export declare interface CategorizationConfig {
|
|
68
|
+
enabled?: boolean
|
|
69
|
+
social?: CategoryPatternConfig
|
|
70
|
+
forums?: CategoryPatternConfig
|
|
71
|
+
updates?: CategoryPatternConfig
|
|
72
|
+
promotions?: CategoryPatternConfig
|
|
73
|
+
}
|
|
74
|
+
export declare interface ImapServerConfig {
|
|
75
|
+
port?: number
|
|
76
|
+
sslPort?: number
|
|
77
|
+
host?: string
|
|
78
|
+
region?: string
|
|
79
|
+
bucket: string
|
|
80
|
+
prefix?: string
|
|
81
|
+
domain: string
|
|
82
|
+
users: Record<string, { password: string; email: string }>
|
|
83
|
+
tls?: {
|
|
84
|
+
key: string
|
|
85
|
+
cert: string
|
|
86
|
+
}
|
|
87
|
+
categorization?: CategorizationConfig
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Email category types for automatic classification
|
|
91
|
+
*/
|
|
92
|
+
export type EmailCategory = 'social' | 'forums' | 'updates' | 'promotions' | 'primary';
|
|
93
|
+
/**
|
|
94
|
+
* IMAP Server that reads emails from S3
|
|
95
|
+
*/
|
|
96
|
+
export declare class ImapServer {
|
|
97
|
+
constructor(config: ImapServerConfig);
|
|
98
|
+
start(): Promise<void>;
|
|
99
|
+
stop(): Promise<void>;
|
|
100
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import*as net from"node:net";import*as tls from"node:tls";import*as fs from"node:fs";import*as crypto from"node:crypto";import{S3Client}from"@stacksjs/ts-cloud";export const CATEGORY_PATTERNS={social:{domains:["facebookmail.com","facebook.com","fb.com","twitter.com","x.com","linkedin.com","linkedinmail.com","instagram.com","tiktok.com","snapchat.com","pinterest.com","reddit.com","redditmail.com","tumblr.com","discord.com","discordapp.com","slack.com","whatsapp.com","telegram.org","signal.org","nextdoor.com","meetup.com","bumble.com","tinder.com","hinge.co","strava.com","twitch.tv","mastodon.social","threads.net","bluesky.social"],substrings:["notification","noreply","no-reply","@social.","@notify."],headers:{"x-mailer":["facebook","twitter","linkedin"]}},forums:{domains:["googlegroups.com","groups.google.com","groups.io","yahoogroups.com","discourse.org","mailman.","lists.","freelists.org","topica.com","listserv.","gaggle.email","simplelists.com","mailchimp.com","stackexchange.com","stackoverflow.com","quora.com","dev.to","hackernews.com","lobste.rs","producthunt.com"],substrings:["-list@","-users@","-dev@","-announce@","mailing-list","mailinglist","discussion@","forum@","community@","@lists.","@list."],headers:{"list-unsubscribe":[""],"list-id":[""],"x-mailing-list":[""],precedence:["list","bulk"]}},updates:{domains:["github.com","gitlab.com","bitbucket.org","circleci.com","travis-ci.com","vercel.com","netlify.com","heroku.com","aws.amazon.com","amazonses.com","cloud.google.com","azure.microsoft.com","digitalocean.com","linode.com","sentry.io","bugsnag.com","datadog.com","newrelic.com","pagerduty.com","opsgenie.com","atlassian.com","jira.com","notion.so","clickup.com","asana.com","linear.app","paypal.com","venmo.com","cashapp.com","stripe.com","squareup.com","intuit.com","quickbooks.com","chase.com","wellsfargo.com","bankofamerica.com","capitalone.com","citi.com","discover.com","americanexpress.com","amex.com","ups.com","fedex.com","usps.com","dhl.com","amazon.com","booking.com","airbnb.com","vrbo.com","expedia.com","kayak.com","tripadvisor.com","uber.com","lyft.com","delta.com","united.com","aa.com","southwest.com","apple.com","google.com","microsoft.com","dropbox.com","box.com","zoom.us","calendly.com","docusign.com","hellosign.com"],substrings:["alert@","alerts@","notification@","notifications@","update@","updates@","status@","confirm@","confirmation@","receipt@","invoice@","billing@","payment@","security@","account@","verify@","shipping@","delivery@","tracking@","support@","help@","service@","donotreply@","do-not-reply@","do_not_reply@","@mail.","@email.","@e.","@em."],headers:{"x-auto-response-suppress":[""],"auto-submitted":["auto-generated","auto-replied"]}},promotions:{domains:["mailchimp.com","sendgrid.net","sendgrid.com","constantcontact.com","mailgun.org","mailgun.com","mailjet.com","sendinblue.com","brevo.com","hubspot.com","hubspotemail.net","klaviyo.com","omnisend.com","activecampaign.com","drip.com","convertkit.com","aweber.com","getresponse.com","campaignmonitor.com","createsend.com","sailthru.com","iterable.com","marketo.com","eloqua.com","pardot.com","amazonses.com","amazon.com","ebay.com","etsy.com","walmart.com","target.com","bestbuy.com","macys.com","nordstrom.com","kohls.com","nike.com","adidas.com","gap.com","oldnavy.com","zara.com","hm.com","sephora.com","ulta.com","wayfair.com","overstock.com","homedepot.com","lowes.com","costco.com","samsclub.com","doordash.com","grubhub.com","ubereats.com","postmates.com","instacart.com","dominos.com","pizzahut.com","papajohns.com","starbucks.com","dunkindonuts.com","chipotle.com","netflix.com","hulu.com","disneyplus.com","hbomax.com","peacocktv.com","paramountplus.com","spotify.com","apple.com","audible.com","kindle.com","ticketmaster.com","stubhub.com","eventbrite.com","groupon.com","livingsocial.com","retailmenot.com","slickdeals.net","honey.com","rakuten.com"],substrings:["promo@","promotions@","marketing@","deals@","offers@","sale@","sales@","newsletter@","news@","digest@","shop@","store@","order@","orders@","rewards@","loyalty@","members@","@promo.","@marketing.","@deals.","@offers.","@shop.","@store.","unsubscribe"],headers:{"x-campaign":[""],"x-mailchimp-":[""],"x-sg-":[""],"x-ses-":[""]}}};function mergePatterns(defaults,custom){if(!custom)return defaults;const result={...defaults};for(const category of["social","forums","updates","promotions"]){const customCat=custom[category];if(customCat)result[category]={domains:customCat.domains||defaults[category].domains,substrings:customCat.substrings||defaults[category].substrings,headers:customCat.headers||defaults[category].headers}}return result}export function categorizeEmail(from,headers,customPatterns){const fromLower=from.toLowerCase(),patterns=mergePatterns(CATEGORY_PATTERNS,customPatterns);for(const[category,categoryPatterns]of Object.entries(patterns)){for(const domain of categoryPatterns.domains)if(fromLower.includes(domain))return category;for(const substring of categoryPatterns.substrings)if(fromLower.includes(substring))return category;if(categoryPatterns.headers)for(const[headerName,headerValues]of Object.entries(categoryPatterns.headers)){const headerValue=headers[headerName.toLowerCase()]||"";if(headerValue){if(headerValues.length===0||headerValues[0]==="")return category;for(const value of headerValues)if(headerValue.toLowerCase().includes(value.toLowerCase()))return category}}}return"primary"}export class ImapServer{config;s3;server;tlsServer;sessions=new Map;messageCache=new Map;flagsCache=new Map;categorizedCache=new Map;uidMappingCache=new Map;nextUidCache=new Map;cacheTimestamp=new Map;uidCounter=new Map;CACHE_TTL_MS=1e4;constructor(config){this.config={port:143,sslPort:993,host:"0.0.0.0",prefix:"incoming/",...config};this.s3=new S3Client(config.region||"us-east-1")}async start(){this.server=net.createServer((socket)=>{this.handleConnection(socket)});this.server.listen(this.config.port,this.config.host,()=>{console.log(`IMAP server listening on ${this.config.host}:${this.config.port}`)});if(this.config.tls?.key&&this.config.tls?.cert){const tlsOptions={key:fs.readFileSync(this.config.tls.key),cert:fs.readFileSync(this.config.tls.cert)};this.tlsServer=tls.createServer(tlsOptions,(socket)=>{this.handleConnection(socket)});this.tlsServer.listen(this.config.sslPort,this.config.host,()=>{console.log(`IMAPS server listening on ${this.config.host}:${this.config.sslPort}`)})}}async stop(){if(this.server)this.server.close();if(this.tlsServer)this.tlsServer.close();for(const session of this.sessions.values())session.socket.destroy();this.sessions.clear()}handleConnection(socket){const sessionId=crypto.randomUUID(),session={id:sessionId,socket,state:"not_authenticated"};this.sessions.set(sessionId,session);this.send(session,`* OK [CAPABILITY IMAP4rev1 STARTTLS AUTH=PLAIN] ${this.config.domain} IMAP4rev1 ready`);let buffer="";socket.on("data",async(data)=>{const chunk=typeof data==="string"?Buffer.from(data):data;if(session.pendingLiteral){const pending=session.pendingLiteral,bytesToRead=Math.min(chunk.length,pending.size-pending.collected);chunk.copy(pending.data,pending.collected,0,bytesToRead);pending.collected+=bytesToRead;if(pending.collected>=pending.size){try{await this.completeAppend(session,pending)}catch(err){console.error(`Error completing APPEND: ${err}`);this.send(session,`${pending.tag} NO APPEND failed: ${err}`)}session.pendingLiteral=void 0;if(bytesToRead<chunk.length)buffer+=chunk.subarray(bytesToRead).toString()}return}buffer+=chunk.toString();let lineEnd;while((lineEnd=buffer.indexOf(`\r
|
|
2
|
+
`))!==-1){const line=buffer.substring(0,lineEnd);buffer=buffer.substring(lineEnd+2);try{await this.processCommand(session,line)}catch(err){console.error(`Error processing command: ${err}`);this.send(session,"* BAD Internal server error")}}});socket.on("close",()=>{this.sessions.delete(sessionId)});socket.on("error",(err)=>{console.error(`Socket error: ${err}`);this.sessions.delete(sessionId)})}send(session,message){session.socket.write(`${message}\r
|
|
3
|
+
`)}async processCommand(session,line){console.log(`IMAP CMD [${session.username||"unauth"}]: ${line}`);if(line.toUpperCase()==="DONE"){if(session.idling&&session.idleTag){session.idling=!1;this.send(session,`${session.idleTag} OK IDLE terminated`);session.idleTag=void 0}return}const match=line.match(/^(\S+)\s+(\S+)(?:\s+(.*))?$/);if(!match){this.send(session,"* BAD Invalid command");return}const[,tag="",command="",args]=match,cmd=command.toUpperCase();switch(cmd){case"CAPABILITY":await this.handleCapability(session,tag);break;case"NOOP":this.send(session,`${tag} OK NOOP completed`);break;case"LOGOUT":await this.handleLogout(session,tag);break;case"LOGIN":await this.handleLogin(session,tag,args||"");break;case"AUTHENTICATE":await this.handleAuthenticate(session,tag,args||"");break;case"SELECT":await this.handleSelect(session,tag,args||"");break;case"EXAMINE":await this.handleExamine(session,tag,args||"");break;case"LIST":await this.handleList(session,tag,args||"");break;case"LSUB":await this.handleLsub(session,tag,args||"");break;case"STATUS":await this.handleStatus(session,tag,args||"");break;case"FETCH":await this.handleFetch(session,tag,args||"");break;case"UID":await this.handleUid(session,tag,args||"");break;case"SEARCH":await this.handleSearch(session,tag,args||"");break;case"CLOSE":await this.handleClose(session,tag);break;case"EXPUNGE":await this.handleExpunge(session,tag);break;case"STORE":await this.handleStore(session,tag,args||"");break;case"COPY":await this.handleCopy(session,tag,args||"");break;case"IDLE":await this.handleIdle(session,tag);break;case"STARTTLS":await this.handleStartTls(session,tag);break;case"NAMESPACE":await this.handleNamespace(session,tag);break;case"XLIST":await this.handleXlist(session,tag,args||"");break;case"CREATE":await this.handleCreate(session,tag,args||"");break;case"DELETE":await this.handleDelete(session,tag,args||"");break;case"SUBSCRIBE":await this.handleSubscribe(session,tag,args||"");break;case"UNSUBSCRIBE":await this.handleUnsubscribe(session,tag,args||"");break;case"RENAME":await this.handleRename(session,tag,args||"");break;case"APPEND":await this.handleAppend(session,tag,args||"");break;case"CHECK":await this.handleCheck(session,tag);break;case"MOVE":await this.handleMove(session,tag,args||"");break;default:this.send(session,`${tag} BAD Unknown command ${cmd}`)}}async handleCapability(session,tag){const capabilities=["IMAP4rev1","STARTTLS","AUTH=PLAIN","AUTH=LOGIN","IDLE","NAMESPACE","UIDPLUS","UNSELECT","CHILDREN","SPECIAL-USE","MOVE"];this.send(session,`* CAPABILITY ${capabilities.join(" ")}`);this.send(session,`${tag} OK CAPABILITY completed`)}async handleLogout(session,tag){this.send(session,`* BYE ${this.config.domain} IMAP4rev1 server logging out`);this.send(session,`${tag} OK LOGOUT completed`);session.state="logout";session.socket.end()}async handleLogin(session,tag,args){const match=args.match(/^"?([^"\s]+)"?\s+"?([^"\s]+)"?$/);if(!match){this.send(session,`${tag} BAD Invalid LOGIN syntax`);return}const[,username="",password=""]=match,user=this.config.users[username];if(!user||user.password!==password){this.send(session,`${tag} NO LOGIN failed`);return}session.username=username;session.email=user.email;session.state="authenticated";this.send(session,`${tag} OK LOGIN completed`)}async handleAuthenticate(session,tag,args){this.send(session,`${tag} NO Use LOGIN command`)}async handleSelect(session,tag,args){if(session.state==="not_authenticated"){this.send(session,`${tag} NO Must authenticate first`);return}const mailbox=args.replace(/^"(.*)"$/,"$1").toUpperCase();session.selectedMailbox=mailbox;session.state="selected";await this.loadMessages(session,!0);const messages=this.getMessagesForFolder(session.email||"",mailbox),exists=messages.length,recent=0,unseenIdx=messages.findIndex((m)=>!m.flags.includes("\\Seen")),unseen=unseenIdx>=0?unseenIdx+1:0,uidnext=this.getUidCounterForFolder(session.email||"",mailbox)+1,uidvalidity=this.getUidValidity(session.email||"");this.send(session,`* ${exists} EXISTS`);this.send(session,`* ${recent} RECENT`);this.send(session,`* OK [UNSEEN ${unseen||1}] First unseen message`);this.send(session,`* OK [UIDVALIDITY ${uidvalidity}]`);this.send(session,`* OK [UIDNEXT ${uidnext}]`);this.send(session,"* FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft)");this.send(session,"* OK [PERMANENTFLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft \\*)]");this.send(session,`${tag} OK [READ-WRITE] SELECT completed`)}async handleExamine(session,tag,args){await this.handleSelect(session,tag,args)}async handleNoop(session,tag){if(session.state==="selected"&&session.selectedMailbox==="INBOX"){const oldCount=this.getMessagesForFolder(session.email||"",session.selectedMailbox).length;await this.loadMessages(session,!0);const newCount=this.getMessagesForFolder(session.email||"",session.selectedMailbox).length;if(newCount!==oldCount){this.send(session,`* ${newCount} EXISTS`);this.send(session,"* 0 RECENT")}}this.send(session,`${tag} OK NOOP completed`)}async handleCheck(session,tag){if(session.state==="selected"){await this.loadMessages(session,!0);const messages=this.getMessagesForFolder(session.email||"",session.selectedMailbox||"INBOX");this.send(session,`* ${messages.length} EXISTS`)}this.send(session,`${tag} OK CHECK completed`)}async handleList(session,tag,args){if(session.state==="not_authenticated"){this.send(session,`${tag} NO Must authenticate first`);return}const match=args.match(/^"?([^"]*)"?\s+"?([^"]*)"?$/);if(!match){this.send(session,`${tag} BAD Invalid LIST syntax`);return}const[,reference="",pattern=""]=match;if(pattern==="*"||pattern==="%"||pattern===""){this.send(session,'* LIST (\\HasNoChildren) "/" "INBOX"');this.send(session,'* LIST (\\HasNoChildren \\Sent) "/" "Sent"');this.send(session,'* LIST (\\HasNoChildren \\Drafts) "/" "Drafts"');this.send(session,'* LIST (\\HasNoChildren \\Trash) "/" "Trash"');this.send(session,'* LIST (\\HasNoChildren \\Junk) "/" "Junk"');this.send(session,'* LIST (\\HasNoChildren \\Archive) "/" "Archive"');this.send(session,'* LIST (\\HasNoChildren \\All) "/" "All Mail"');this.send(session,'* LIST (\\HasNoChildren \\Flagged) "/" "Starred"');this.send(session,'* LIST (\\HasNoChildren \\Important) "/" "Important"');this.send(session,'* LIST (\\HasNoChildren) "/" "Social"');this.send(session,'* LIST (\\HasNoChildren) "/" "Forums"');this.send(session,'* LIST (\\HasNoChildren) "/" "Updates"');this.send(session,'* LIST (\\HasNoChildren) "/" "Promotions"')}else if(pattern.toUpperCase()==="INBOX")this.send(session,'* LIST (\\HasNoChildren) "/" "INBOX"');else{const upperPattern=pattern.toUpperCase();if(upperPattern==="SENT"||upperPattern==="*SENT*")this.send(session,'* LIST (\\HasNoChildren \\Sent) "/" "Sent"');if(upperPattern==="DRAFTS"||upperPattern==="*DRAFT*")this.send(session,'* LIST (\\HasNoChildren \\Drafts) "/" "Drafts"');if(upperPattern==="TRASH"||upperPattern==="*TRASH*")this.send(session,'* LIST (\\HasNoChildren \\Trash) "/" "Trash"');if(upperPattern==="JUNK"||upperPattern==="SPAM"||upperPattern==="*JUNK*"||upperPattern==="*SPAM*")this.send(session,'* LIST (\\HasNoChildren \\Junk) "/" "Junk"');if(upperPattern==="ARCHIVE"||upperPattern==="*ARCHIVE*")this.send(session,'* LIST (\\HasNoChildren \\Archive) "/" "Archive"')}this.send(session,`${tag} OK LIST completed`)}async handleLsub(session,tag,args){if(session.state==="not_authenticated"){this.send(session,`${tag} NO Must authenticate first`);return}const match=args.match(/^"?([^"]*)"?\s+"?([^"]*)"?$/);if(!match){this.send(session,`${tag} BAD Invalid LSUB syntax`);return}const[,reference="",pattern=""]=match;if(pattern==="*"||pattern==="%"||pattern===""){this.send(session,'* LSUB (\\HasNoChildren) "/" "INBOX"');this.send(session,'* LSUB (\\HasNoChildren \\Sent) "/" "Sent"');this.send(session,'* LSUB (\\HasNoChildren \\Drafts) "/" "Drafts"');this.send(session,'* LSUB (\\HasNoChildren \\Trash) "/" "Trash"');this.send(session,'* LSUB (\\HasNoChildren \\Junk) "/" "Junk"');this.send(session,'* LSUB (\\HasNoChildren \\Archive) "/" "Archive"');this.send(session,'* LSUB (\\HasNoChildren \\All) "/" "All Mail"')}else if(pattern.toUpperCase()==="INBOX")this.send(session,'* LSUB (\\HasNoChildren) "/" "INBOX"');this.send(session,`${tag} OK LSUB completed`)}async handleStatus(session,tag,args){if(session.state==="not_authenticated"){this.send(session,`${tag} NO Must authenticate first`);return}const match=args.match(/^"?([^"]+)"?\s+\(([^)]+)\)$/i);if(!match){this.send(session,`${tag} BAD Invalid STATUS syntax`);return}const[,mailbox="",itemsStr=""]=match;await this.loadMessagesForFolder(session,mailbox);const messages=this.getMessagesForFolder(session.email||"",mailbox)||[],items=itemsStr.toUpperCase().split(/\s+/),results=[];for(const item of items)switch(item){case"MESSAGES":results.push(`MESSAGES ${messages.length}`);break;case"RECENT":results.push("RECENT 0");break;case"UIDNEXT":results.push(`UIDNEXT ${this.getUidCounterForFolder(session.email||"",mailbox)+1}`);break;case"UIDVALIDITY":results.push(`UIDVALIDITY ${this.getUidValidity(session.email||"")}`);break;case"UNSEEN":results.push(`UNSEEN ${messages.filter((m)=>!m.flags.includes("\\Seen")).length}`);break}this.send(session,`* STATUS "${mailbox}" (${results.join(" ")})`);this.send(session,`${tag} OK STATUS completed`)}async handleFetch(session,tag,args){if(session.state!=="selected"){this.send(session,`${tag} NO Must select mailbox first`);return}const match=args.match(/^(\S+)\s+(.+)$/i);if(!match){this.send(session,`${tag} BAD Invalid FETCH syntax`);return}const[,sequenceSet="",itemsStr=""]=match,folder=session.selectedMailbox||"INBOX",messages=this.getMessagesForFolder(session.email||"",folder),indices=this.parseSequenceSet(sequenceSet,messages.length);for(const idx of indices){const msg=messages[idx-1];if(!msg)continue;const fetchData=await this.buildFetchResponse(session,msg,idx,itemsStr);this.send(session,`* ${idx} FETCH ${fetchData}`)}this.send(session,`${tag} OK FETCH completed`)}async handleUid(session,tag,args){if(session.state!=="selected"){this.send(session,`${tag} NO Must select mailbox first`);return}const match=args.match(/^(\S+)\s+(.+)$/i);if(!match){this.send(session,`${tag} BAD Invalid UID syntax`);return}const[,subCommand="",subArgs=""]=match;switch(subCommand.toUpperCase()){case"FETCH":await this.handleUidFetch(session,tag,subArgs);break;case"SEARCH":await this.handleUidSearch(session,tag,subArgs);break;case"STORE":await this.handleUidStore(session,tag,subArgs);break;case"COPY":await this.handleUidCopy(session,tag,subArgs);break;case"MOVE":await this.handleUidMove(session,tag,subArgs);break;case"EXPUNGE":await this.handleUidExpunge(session,tag,subArgs);break;default:this.send(session,`${tag} BAD Unknown UID command`)}}async handleUidFetch(session,tag,args){const match=args.match(/^(\S+)\s+(.+)$/i);if(!match){this.send(session,`${tag} BAD Invalid UID FETCH syntax`);return}const[,uidSet="",itemsStr=""]=match,folder=session.selectedMailbox||"INBOX",messages=this.getMessagesForFolder(session.email||"",folder),maxUid=messages.length>0?Math.max(...messages.map((m)=>m.uid)):0,uids=this.parseSequenceSet(uidSet,maxUid);for(const uid of uids){const idx=messages.findIndex((m)=>m.uid===uid);if(idx===-1)continue;const msg=messages[idx];if(!msg)continue;const fetchData=await this.buildFetchResponse(session,msg,idx+1,itemsStr,!0);this.send(session,`* ${idx+1} FETCH ${fetchData}`)}this.send(session,`${tag} OK UID FETCH completed`)}async handleSearch(session,tag,args){if(session.state!=="selected"){this.send(session,`${tag} NO Must select mailbox first`);return}const folder=session.selectedMailbox||"INBOX",messages=this.getMessagesForFolder(session.email||"",folder);if(messages.length===0)this.send(session,"* SEARCH");else{const results=messages.map((_,i)=>i+1);this.send(session,`* SEARCH ${results.join(" ")}`)}this.send(session,`${tag} OK SEARCH completed`)}async handleUidSearch(session,tag,args){const folder=session.selectedMailbox||"INBOX",messages=this.getMessagesForFolder(session.email||"",folder);if(messages.length===0)this.send(session,"* SEARCH");else{const results=messages.map((m)=>m.uid);this.send(session,`* SEARCH ${results.join(" ")}`)}this.send(session,`${tag} OK UID SEARCH completed`)}async handleStore(session,tag,args){if(session.state!=="selected"){this.send(session,`${tag} NO Must select mailbox first`);return}const match=args.match(/^(\S+)\s+([+-]?FLAGS(?:\.SILENT)?)\s+\(([^)]*)\)/i);if(!match){this.send(session,`${tag} BAD Invalid STORE syntax`);return}const[,sequenceSet="",operation="",flagsStr=""]=match,folder=session.selectedMailbox||"INBOX",messages=this.getMessagesForFolder(session.email||"",folder),indices=this.parseSequenceSet(sequenceSet,messages.length),silent=operation.toUpperCase().includes(".SILENT"),flags=flagsStr.split(/\s+/).filter((f)=>f),persistedFlags=await this.loadFlags(session.email||"");for(const idx of indices){const msg=messages[idx-1];if(!msg)continue;if(operation.startsWith("+")){for(const flag of flags)if(!msg.flags.includes(flag))msg.flags.push(flag)}else if(operation.startsWith("-"))msg.flags=msg.flags.filter((f)=>!flags.includes(f));else msg.flags=[...flags];persistedFlags[msg.key]=[...msg.flags];if(!silent)this.send(session,`* ${idx} FETCH (FLAGS (${msg.flags.join(" ")}))`)}await this.saveFlags(session.email||"");this.send(session,`${tag} OK STORE completed`)}async handleUidStore(session,tag,args){if(session.state!=="selected"){this.send(session,`${tag} NO Must select mailbox first`);return}const match=args.match(/^(\S+)\s+([+-]?FLAGS(?:\.SILENT)?)\s+\(([^)]*)\)/i);if(!match){this.send(session,`${tag} BAD Invalid UID STORE syntax`);return}const[,uidSet="",operation="",flagsStr=""]=match,folder=session.selectedMailbox||"INBOX",messages=this.getMessagesForFolder(session.email||"",folder),maxUid=messages.length>0?Math.max(...messages.map((m)=>m.uid)):0,uids=this.parseSequenceSet(uidSet,maxUid),silent=operation.toUpperCase().includes(".SILENT"),flags=flagsStr.split(/\s+/).filter((f)=>f),persistedFlags=await this.loadFlags(session.email||"");for(const uid of uids){const idx=messages.findIndex((m)=>m.uid===uid);if(idx===-1)continue;const msg=messages[idx];if(!msg)continue;if(operation.startsWith("+")){for(const flag of flags)if(!msg.flags.includes(flag))msg.flags.push(flag)}else if(operation.startsWith("-"))msg.flags=msg.flags.filter((f)=>!flags.includes(f));else msg.flags=[...flags];persistedFlags[msg.key]=[...msg.flags];if(!silent)this.send(session,`* ${idx+1} FETCH (UID ${msg.uid} FLAGS (${msg.flags.join(" ")}))`)}await this.saveFlags(session.email||"");this.send(session,`${tag} OK UID STORE completed`)}async handleCopy(session,tag,args){if(session.state!=="selected"){this.send(session,`${tag} NO Must select mailbox first`);return}const match=args.match(/^(\S+)\s+"?([^"]+)"?$/i);if(!match){this.send(session,`${tag} BAD Invalid COPY syntax`);return}const[,sequenceSet="",destMailbox=""]=match,sourceFolder=session.selectedMailbox||"INBOX",messages=this.getMessagesForFolder(session.email||"",sourceFolder),indices=this.parseSequenceSet(sequenceSet,messages.length),destPrefix=this.getFolderPrefix(destMailbox);for(const idx of indices){const msg=messages[idx-1];if(!msg)continue;try{const content=msg.raw||await this.s3.getObject(this.config.bucket,msg.key),filename=msg.key.split("/").pop()||`${Date.now()}`,newKey=`${destPrefix}${filename}`;await this.s3.putObject({bucket:this.config.bucket,key:newKey,body:content,contentType:"message/rfc822"});console.log(`Copied message from ${msg.key} to ${newKey}`)}catch(err){console.error(`Failed to copy message: ${err}`)}}this.send(session,`${tag} OK COPY completed`)}async handleUidCopy(session,tag,args){if(session.state!=="selected"){this.send(session,`${tag} NO Must select mailbox first`);return}const match=args.match(/^(\S+)\s+"?([^"]+)"?$/i);if(!match){this.send(session,`${tag} BAD Invalid UID COPY syntax`);return}const[,uidSet="",destMailbox=""]=match,sourceFolder=session.selectedMailbox||"INBOX",messages=this.getMessagesForFolder(session.email||"",sourceFolder),maxUid=messages.length>0?Math.max(...messages.map((m)=>m.uid)):0,uids=this.parseSequenceSet(uidSet,maxUid),destPrefix=this.getFolderPrefix(destMailbox);for(const uid of uids){const msg=messages.find((m)=>m.uid===uid);if(!msg)continue;try{const content=msg.raw||await this.s3.getObject(this.config.bucket,msg.key),filename=msg.key.split("/").pop()||`${Date.now()}`,newKey=`${destPrefix}${filename}`;await this.s3.putObject({bucket:this.config.bucket,key:newKey,body:content,contentType:"message/rfc822"});console.log(`UID COPY: Copied message from ${msg.key} to ${newKey}`)}catch(err){console.error(`Failed to copy message: ${err}`)}}this.send(session,`${tag} OK UID COPY completed`)}async handleMove(session,tag,args){if(session.state!=="selected"){this.send(session,`${tag} NO Must select mailbox first`);return}const match=args.match(/^(\S+)\s+"?([^"]+)"?$/i);if(!match){this.send(session,`${tag} BAD Invalid MOVE syntax`);return}const[,sequenceSet="",destMailbox=""]=match,sourceFolder=session.selectedMailbox||"INBOX",messages=this.getMessagesForFolder(session.email||"",sourceFolder),indices=this.parseSequenceSet(sequenceSet,messages.length),destPrefix=this.getFolderPrefix(destMailbox),movedIndices=[];for(const idx of indices){const msg=messages[idx-1];if(!msg)continue;try{const content=msg.raw||await this.s3.getObject(this.config.bucket,msg.key),filename=msg.key.split("/").pop()||`${Date.now()}`,newKey=`${destPrefix}${filename}`;await this.s3.putObject({bucket:this.config.bucket,key:newKey,body:content,contentType:"message/rfc822"});await this.s3.deleteObject(this.config.bucket,msg.key);console.log(`MOVE: ${msg.key} -> ${newKey}`);movedIndices.push(idx)}catch(err){console.error(`Failed to move message: ${err}`)}}const sortedIndices=[...movedIndices].sort((a,b)=>b-a);for(const idx of sortedIndices)this.send(session,`* ${idx} EXPUNGE`);const remainingMessages=messages.filter((_,i)=>!movedIndices.includes(i+1));this.setMessagesForFolder(session.email||"",sourceFolder,remainingMessages);this.send(session,`* ${remainingMessages.length} EXISTS`);this.send(session,`${tag} OK MOVE completed`)}async handleUidMove(session,tag,args){if(session.state!=="selected"){this.send(session,`${tag} NO Must select mailbox first`);return}const match=args.match(/^(\S+)\s+"?([^"]+)"?$/i);if(!match){this.send(session,`${tag} BAD Invalid UID MOVE syntax`);return}const[,uidSet="",destMailbox=""]=match,sourceFolder=session.selectedMailbox||"INBOX",messages=this.getMessagesForFolder(session.email||"",sourceFolder),maxUid=messages.length>0?Math.max(...messages.map((m)=>m.uid)):0,uids=this.parseSequenceSet(uidSet,maxUid),destPrefix=this.getFolderPrefix(destMailbox),movedIndices=[];for(const uid of uids){const idx=messages.findIndex((m)=>m.uid===uid);if(idx===-1)continue;const msg=messages[idx];if(!msg)continue;try{const content=msg.raw||await this.s3.getObject(this.config.bucket,msg.key),filename=msg.key.split("/").pop()||`${Date.now()}`,newKey=`${destPrefix}${filename}`;await this.s3.putObject({bucket:this.config.bucket,key:newKey,body:content,contentType:"message/rfc822"});await this.s3.deleteObject(this.config.bucket,msg.key);console.log(`UID MOVE: ${msg.key} -> ${newKey}`);movedIndices.push(idx+1)}catch(err){console.error(`Failed to move message: ${err}`)}}const sortedIndices=[...movedIndices].sort((a,b)=>b-a);for(const idx of sortedIndices)this.send(session,`* ${idx} EXPUNGE`);const remainingMessages=messages.filter((_,i)=>!movedIndices.includes(i+1));this.setMessagesForFolder(session.email||"",sourceFolder,remainingMessages);this.send(session,`* ${remainingMessages.length} EXISTS`);this.send(session,`${tag} OK UID MOVE completed`)}async handleUidExpunge(session,tag,args){if(session.state!=="selected"){this.send(session,`${tag} NO Must select mailbox first`);return}const folder=session.selectedMailbox||"INBOX",messages=this.getMessagesForFolder(session.email||"",folder),uidSet=args.trim(),maxUid=messages.length>0?Math.max(...messages.map((m)=>m.uid)):0,uids=this.parseSequenceSet(uidSet,maxUid),indicesToExpunge=[],keysToDelete=[];for(let i=messages.length-1;i>=0;i--){const msg=messages[i];if(!msg)continue;if(uids.includes(msg.uid)&&msg.flags.includes("\\Deleted")){indicesToExpunge.push(i+1);keysToDelete.push(msg.key)}}for(const key of keysToDelete)try{await this.s3.deleteObject(this.config.bucket,key);console.log(`Deleted from S3: ${key}`)}catch(err){console.error(`Failed to delete from S3: ${key}`,err)}for(const idx of indicesToExpunge)this.send(session,`* ${idx} EXPUNGE`);const remainingMessages=messages.filter((_,i)=>!indicesToExpunge.includes(i+1));this.setMessagesForFolder(session.email||"",folder,remainingMessages);if(indicesToExpunge.length>0)this.send(session,`* ${remainingMessages.length} EXISTS`);this.send(session,`${tag} OK UID EXPUNGE completed`)}async handleClose(session,tag){session.state="authenticated";session.selectedMailbox=void 0;this.send(session,`${tag} OK CLOSE completed`)}async handleExpunge(session,tag){if(session.state!=="selected"){this.send(session,`${tag} NO Must select mailbox first`);return}const folder=session.selectedMailbox||"INBOX",messages=this.getMessagesForFolder(session.email||"",folder),indicesToExpunge=[],keysToDelete=[];for(let i=messages.length-1;i>=0;i--){const message=messages[i];if(message?.flags.includes("\\Deleted")){indicesToExpunge.push(i+1);keysToDelete.push(message.key)}}for(const key of keysToDelete)try{await this.s3.deleteObject(this.config.bucket,key);console.log(`EXPUNGE: Deleted from S3: ${key}`)}catch(err){console.error(`Failed to delete from S3: ${key}`,err)}for(const idx of indicesToExpunge)this.send(session,`* ${idx} EXPUNGE`);const remainingMessages=messages.filter((m)=>!m.flags.includes("\\Deleted"));this.setMessagesForFolder(session.email||"",folder,remainingMessages);if(indicesToExpunge.length>0)this.send(session,`* ${remainingMessages.length} EXISTS`);this.send(session,`${tag} OK EXPUNGE completed`)}async handleIdle(session,tag){session.idling=!0;session.idleTag=tag;this.send(session,"+ idling")}async handleStartTls(session,tag){if(!this.config.tls?.key||!this.config.tls?.cert){this.send(session,`${tag} NO TLS not configured`);return}this.send(session,`${tag} OK Begin TLS negotiation`);const tlsOptions={key:fs.readFileSync(this.config.tls.key),cert:fs.readFileSync(this.config.tls.cert)},tlsSocket=new tls.TLSSocket(session.socket,tlsOptions);session.socket=tlsSocket}async handleNamespace(session,tag){this.send(session,'* NAMESPACE (("" "/")) NIL NIL');this.send(session,`${tag} OK NAMESPACE completed`)}async loadMessages(session,forceRefresh=!1){const email=session.email,folder=session.selectedMailbox||"INBOX";if(!email)return;const cacheKey=`${email}:${folder}`,lastUpdate=this.cacheTimestamp.get(cacheKey)||0,cacheAge=Date.now()-lastUpdate;if(!forceRefresh&&cacheAge<this.CACHE_TTL_MS){if(this.getMessagesForFolder(email,folder).length>0)return}const persistedFlags=await this.loadFlags(email);await this.loadUidMapping(email);try{if(this.isAllMailFolder(folder)){await this.loadAllMailFolder(email,persistedFlags);return}if(this.isStarredFolder(folder)){await this.loadStarredFolder(email,persistedFlags);return}if(this.isImportantFolder(folder)){await this.loadImportantFolder(email,persistedFlags);return}const prefix=this.getFolderPrefix(folder),objects=await this.s3.list({bucket:this.config.bucket,prefix,maxKeys:1000}),messages=[];let hasNewMessages=!1;for(const obj of objects){if(!obj.Key)continue;let raw="";try{raw=await this.s3.getObject(this.config.bucket,obj.Key)}catch{continue}const headers=this.parseHeaders(raw);if(folder==="INBOX"){if(!(headers.to||"").toLowerCase().includes(email.toLowerCase()))continue;if(this.config.categorization?.enabled)await this.autoCategorizeEmail(email,obj.Key,headers.from||"",headers,raw)}const existingUid=this.uidMappingCache.get(email)?.[obj.Key],uid=this.getOrAssignUid(email,obj.Key);if(!existingUid)hasNewMessages=!0;const flags=persistedFlags[obj.Key]||[];messages.push({uid,key:obj.Key,size:obj.Size||raw.length,date:new Date(obj.LastModified||Date.now()),flags:[...flags],from:headers.from,to:headers.to,subject:headers.subject,raw})}messages.sort((a,b)=>a.uid-b.uid);if(hasNewMessages)await this.saveUidMapping(email);const maxUid=messages.length>0?Math.max(...messages.map((m)=>m.uid)):0;this.setUidCounterForFolder(email,folder,maxUid);this.setMessagesForFolder(email,folder,messages);this.cacheTimestamp.set(cacheKey,Date.now());console.log(`Loaded ${messages.length} messages for ${email} in ${folder} from S3 (hasNewMessages=${hasNewMessages})`)}catch(err){console.error(`Error loading messages from S3: ${err}`)}}async loadAllMailFolder(email,persistedFlags){const folder="ALL MAIL",cacheKey=`${email}:${folder}`;await this.loadUidMapping(email);const allPrefixes=[this.config.prefix||"incoming/","sent/","trash/","drafts/","junk/","archive/"],allMessages=[],seenKeys=new Set;let hasNewMessages=!1;for(const prefix of allPrefixes)try{const objects=await this.s3.list({bucket:this.config.bucket,prefix,maxKeys:1000});for(const obj of objects){if(!obj.Key||seenKeys.has(obj.Key))continue;seenKeys.add(obj.Key);let raw="";try{raw=await this.s3.getObject(this.config.bucket,obj.Key)}catch{continue}const headers=this.parseHeaders(raw);if(prefix===(this.config.prefix||"incoming/")){if(!(headers.to||"").toLowerCase().includes(email.toLowerCase()))continue}const existingUid=this.uidMappingCache.get(email)?.[obj.Key],uid=this.getOrAssignUid(email,obj.Key);if(!existingUid)hasNewMessages=!0;const flags=persistedFlags[obj.Key]||[];allMessages.push({uid,key:obj.Key,size:obj.Size||raw.length,date:new Date(obj.LastModified||Date.now()),flags:[...flags],from:headers.from,to:headers.to,subject:headers.subject,raw})}}catch(err){console.error(`Error loading messages from prefix ${prefix}: ${err}`)}allMessages.sort((a,b)=>a.uid-b.uid);if(hasNewMessages)await this.saveUidMapping(email);const maxUid=allMessages.length>0?Math.max(...allMessages.map((m)=>m.uid)):0;this.setUidCounterForFolder(email,folder,maxUid);this.setMessagesForFolder(email,folder,allMessages);this.cacheTimestamp.set(cacheKey,Date.now());console.log(`Loaded ${allMessages.length} total messages for ${email} in All Mail from S3 (hasNewMessages=${hasNewMessages})`)}async loadStarredFolder(email,persistedFlags){const folder="STARRED",cacheKey=`${email}:${folder}`;await this.loadUidMapping(email);const allPrefixes=[this.config.prefix||"incoming/","sent/","trash/","drafts/","junk/","archive/","categories/social/","categories/forums/","categories/updates/","categories/promotions/"],starredMessages=[],seenKeys=new Set;let hasNewMessages=!1;for(const prefix of allPrefixes)try{const objects=await this.s3.list({bucket:this.config.bucket,prefix,maxKeys:1000});for(const obj of objects){if(!obj.Key||seenKeys.has(obj.Key))continue;seenKeys.add(obj.Key);const flags=persistedFlags[obj.Key]||[];if(!flags.includes("\\Flagged"))continue;let raw="";try{raw=await this.s3.getObject(this.config.bucket,obj.Key)}catch{continue}const headers=this.parseHeaders(raw);if(prefix===(this.config.prefix||"incoming/")){if(!(headers.to||"").toLowerCase().includes(email.toLowerCase()))continue}const existingUid=this.uidMappingCache.get(email)?.[obj.Key],uid=this.getOrAssignUid(email,obj.Key);if(!existingUid)hasNewMessages=!0;starredMessages.push({uid,key:obj.Key,size:obj.Size||raw.length,date:new Date(obj.LastModified||Date.now()),flags:[...flags],from:headers.from,to:headers.to,subject:headers.subject,raw})}}catch(err){console.error(`Error loading starred messages from prefix ${prefix}: ${err}`)}starredMessages.sort((a,b)=>a.uid-b.uid);if(hasNewMessages)await this.saveUidMapping(email);const maxUid=starredMessages.length>0?Math.max(...starredMessages.map((m)=>m.uid)):0;this.setUidCounterForFolder(email,folder,maxUid);this.setMessagesForFolder(email,folder,starredMessages);this.cacheTimestamp.set(cacheKey,Date.now());console.log(`Loaded ${starredMessages.length} starred messages for ${email} (hasNewMessages=${hasNewMessages})`)}async loadImportantFolder(email,persistedFlags){const folder="IMPORTANT",cacheKey=`${email}:${folder}`;await this.loadUidMapping(email);const allPrefixes=[this.config.prefix||"incoming/","sent/","trash/","drafts/","junk/","archive/","categories/social/","categories/forums/","categories/updates/","categories/promotions/"],importantMessages=[],seenKeys=new Set;let hasNewMessages=!1;for(const prefix of allPrefixes)try{const objects=await this.s3.list({bucket:this.config.bucket,prefix,maxKeys:1000});for(const obj of objects){if(!obj.Key||seenKeys.has(obj.Key))continue;seenKeys.add(obj.Key);const flags=persistedFlags[obj.Key]||[];if(!(flags.includes("\\Important")||flags.includes("$Important")))continue;let raw="";try{raw=await this.s3.getObject(this.config.bucket,obj.Key)}catch{continue}const headers=this.parseHeaders(raw);if(prefix===(this.config.prefix||"incoming/")){if(!(headers.to||"").toLowerCase().includes(email.toLowerCase()))continue}const existingUid=this.uidMappingCache.get(email)?.[obj.Key],uid=this.getOrAssignUid(email,obj.Key);if(!existingUid)hasNewMessages=!0;importantMessages.push({uid,key:obj.Key,size:obj.Size||raw.length,date:new Date(obj.LastModified||Date.now()),flags:[...flags],from:headers.from,to:headers.to,subject:headers.subject,raw})}}catch(err){console.error(`Error loading important messages from prefix ${prefix}: ${err}`)}importantMessages.sort((a,b)=>a.uid-b.uid);if(hasNewMessages)await this.saveUidMapping(email);const maxUid=importantMessages.length>0?Math.max(...importantMessages.map((m)=>m.uid)):0;this.setUidCounterForFolder(email,folder,maxUid);this.setMessagesForFolder(email,folder,importantMessages);this.cacheTimestamp.set(cacheKey,Date.now());console.log(`Loaded ${importantMessages.length} important messages for ${email} (hasNewMessages=${hasNewMessages})`)}async loadMessagesForFolder(session,folder){const email=session.email;if(!email)return;const cacheKey=`${email}:${folder}`,lastUpdate=this.cacheTimestamp.get(cacheKey)||0;if(Date.now()-lastUpdate<this.CACHE_TTL_MS){if(this.getMessagesForFolder(email,folder).length>0)return}const persistedFlags=await this.loadFlags(email);await this.loadUidMapping(email);if(this.isAllMailFolder(folder)){await this.loadAllMailFolder(email,persistedFlags);return}if(this.isStarredFolder(folder)){await this.loadStarredFolder(email,persistedFlags);return}if(this.isImportantFolder(folder)){await this.loadImportantFolder(email,persistedFlags);return}try{const prefix=this.getFolderPrefix(folder),objects=await this.s3.list({bucket:this.config.bucket,prefix,maxKeys:1000}),messages=[];let hasNewMessages=!1;for(const obj of objects){if(!obj.Key)continue;let raw="";try{raw=await this.s3.getObject(this.config.bucket,obj.Key)}catch{continue}const headers=this.parseHeaders(raw);if(folder.toUpperCase()==="INBOX"){if(!(headers.to||"").toLowerCase().includes(email.toLowerCase()))continue}const existingUid=this.uidMappingCache.get(email)?.[obj.Key],uid=this.getOrAssignUid(email,obj.Key);if(!existingUid)hasNewMessages=!0;const flags=persistedFlags[obj.Key]||[];messages.push({uid,key:obj.Key,size:obj.Size||raw.length,date:new Date(obj.LastModified||Date.now()),flags:[...flags],from:headers.from,to:headers.to,subject:headers.subject,raw})}messages.sort((a,b)=>a.uid-b.uid);if(hasNewMessages)await this.saveUidMapping(email);const maxUid=messages.length>0?Math.max(...messages.map((m)=>m.uid)):0;this.setUidCounterForFolder(email,folder,maxUid);this.setMessagesForFolder(email,folder,messages);this.cacheTimestamp.set(cacheKey,Date.now())}catch(err){console.error(`Error loading messages for folder ${folder}: ${err}`);this.setMessagesForFolder(email,folder,[])}}parseHeaders(raw){const headers={},lines=(raw.split(`\r
|
|
4
|
+
\r
|
|
5
|
+
`)[0]||raw.split(`
|
|
6
|
+
|
|
7
|
+
`)[0]||"").split(/\r?\n/);let currentHeader="",currentValue="";for(const line of lines)if(line.startsWith(" ")||line.startsWith("\t"))currentValue+=` ${line.trim()}`;else{if(currentHeader)headers[currentHeader.toLowerCase()]=currentValue;const colonIdx=line.indexOf(":");if(colonIdx>0){currentHeader=line.substring(0,colonIdx).trim();currentValue=line.substring(colonIdx+1).trim()}}if(currentHeader)headers[currentHeader.toLowerCase()]=currentValue;return headers}async buildFetchResponse(session,msg,seqNum,itemsStr,includeUid=!1){const items=itemsStr.toUpperCase(),results=[];if(includeUid||items.includes("UID"))results.push(`UID ${msg.uid}`);if(items.includes("FLAGS"))results.push(`FLAGS (${msg.flags.join(" ")})`);if(items.includes("RFC822.SIZE"))results.push(`RFC822.SIZE ${msg.size}`);if(items.includes("INTERNALDATE"))results.push(`INTERNALDATE "${this.formatImapDate(msg.date)}"`);if(items.includes("ENVELOPE")){const envelope=this.buildEnvelope(msg);results.push(`ENVELOPE ${envelope}`)}if(items.includes("BODYSTRUCTURE")||items.includes("BODY.PEEK[STRUCTURE]"))results.push(`BODYSTRUCTURE ("TEXT" "PLAIN" ("CHARSET" "UTF-8") NIL NIL "7BIT" ${msg.size} ${Math.ceil(msg.size/80)} NIL NIL NIL)`);if(items.includes("BODY[]")||items.includes("BODY.PEEK[]")||items.includes("RFC822")){const raw=msg.raw||await this.s3.getObject(this.config.bucket,msg.key);results.push(`BODY[] {${raw.length}}\r
|
|
8
|
+
${raw}`)}if(items.includes("BODY[HEADER]")||items.includes("BODY.PEEK[HEADER]")){const raw=msg.raw||await this.s3.getObject(this.config.bucket,msg.key),header=raw.split(`\r
|
|
9
|
+
\r
|
|
10
|
+
`)[0]||raw.split(`
|
|
11
|
+
|
|
12
|
+
`)[0]||"";results.push(`BODY[HEADER] {${header.length+2}}\r
|
|
13
|
+
${header}\r
|
|
14
|
+
`)}if(items.includes("BODY[TEXT]")||items.includes("BODY.PEEK[TEXT]")){const raw=msg.raw||await this.s3.getObject(this.config.bucket,msg.key),text=raw.split(`\r
|
|
15
|
+
\r
|
|
16
|
+
`).slice(1).join(`\r
|
|
17
|
+
\r
|
|
18
|
+
`)||raw.split(`
|
|
19
|
+
|
|
20
|
+
`).slice(1).join(`
|
|
21
|
+
|
|
22
|
+
`);results.push(`BODY[TEXT] {${text.length}}\r
|
|
23
|
+
${text}`)}return`(${results.join(" ")})`}buildEnvelope(msg){const quote=(s)=>s?`"${s.replace(/"/g,"\\\"")}"`:"NIL",date=quote(msg.date.toUTCString()),subject=quote(msg.subject),parseAddr=(addr)=>{const atIdx=addr.indexOf("@"),local=atIdx>=0?addr.slice(0,atIdx):addr,domain=atIdx>=0?addr.slice(atIdx+1):"";return`((NIL NIL "${local.replace(/"/g,"\\\"")}" "${domain.replace(/"/g,"\\\"")}"))`},from=msg.from?parseAddr(msg.from):"NIL",to=msg.to?parseAddr(msg.to):"NIL";return`(${date} ${subject} ${from} ${from} ${from} ${to} NIL NIL NIL NIL)`}formatImapDate(date){const months=["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],d=date.getDate().toString().padStart(2,"0"),m=months[date.getMonth()],y=date.getFullYear(),h=date.getHours().toString().padStart(2,"0"),min=date.getMinutes().toString().padStart(2,"0"),s=date.getSeconds().toString().padStart(2,"0");return`${d}-${m}-${y} ${h}:${min}:${s} ${"+0000"}`}parseSequenceSet(set,max){const results=[];for(const part of set.split(","))if(part.includes(":")){const[start="",end=""]=part.split(":"),s=start==="*"?max:Number.parseInt(start,10),e=end==="*"?max:Number.parseInt(end,10);for(let i=Math.min(s,e);i<=Math.max(s,e);i++)if(i>0&&i<=max)results.push(i)}else{const n=part==="*"?max:Number.parseInt(part,10);if(n>0&&n<=max)results.push(n)}return[...new Set(results)].sort((a,b)=>a-b)}getUidValidity(email){let hash=0;for(let i=0;i<email.length;i++){hash=(hash<<5)-hash+email.charCodeAt(i);hash=hash&hash}return Math.abs(hash)||1}getFolderPrefix(folder){switch(folder.toUpperCase()){case"INBOX":return this.config.prefix||"incoming/";case"TRASH":return"trash/";case"SENT":return"sent/";case"DRAFTS":return"drafts/";case"JUNK":return"junk/";case"ARCHIVE":return"archive/";case"ALL MAIL":return"";case"STARRED":return"";case"IMPORTANT":return"";case"SOCIAL":return"categories/social/";case"FORUMS":return"categories/forums/";case"UPDATES":return"categories/updates/";case"PROMOTIONS":return"categories/promotions/";default:return`folders/${folder.toLowerCase()}/`}}isVirtualFolder(folder){return["ALL MAIL","STARRED","IMPORTANT"].includes(folder.toUpperCase())}isStarredFolder(folder){return folder.toUpperCase()==="STARRED"}isImportantFolder(folder){return folder.toUpperCase()==="IMPORTANT"}isAllMailFolder(folder){return folder.toUpperCase()==="ALL MAIL"}async autoCategorizeEmail(email,s3Key,from,headers,raw){if(!this.config.categorization?.enabled)return"primary";let categorizedKeys=this.categorizedCache.get(email);if(!categorizedKeys){categorizedKeys=await this.loadCategorizedKeys(email);this.categorizedCache.set(email,categorizedKeys)}if(categorizedKeys.has(s3Key))return"primary";const category=categorizeEmail(from,headers,this.config.categorization);if(category!=="primary"){const destPrefix=this.getCategoryPrefix(category),filename=s3Key.split("/").pop()||`${Date.now()}`,newKey=`${destPrefix}${filename}`;try{await this.s3.putObject({bucket:this.config.bucket,key:newKey,body:raw,contentType:"message/rfc822"});console.log(`Auto-categorized ${s3Key} -> ${category} (${newKey})`);categorizedKeys.add(s3Key);await this.saveCategorizedKeys(email)}catch(err){console.error(`Failed to auto-categorize email to ${category}:`,err)}}return category}getCategoryPrefix(category){switch(category){case"social":return"categories/social/";case"forums":return"categories/forums/";case"updates":return"categories/updates/";case"promotions":return"categories/promotions/";default:return this.config.prefix||"incoming/"}}async loadCategorizedKeys(email){try{const key=`categorized/${email.replace("@","_at_")}.json`,content=await this.s3.getObject(this.config.bucket,key),data=JSON.parse(content);return new Set(data.keys||[])}catch{return new Set}}async saveCategorizedKeys(email){const categorizedKeys=this.categorizedCache.get(email);if(!categorizedKeys)return;try{const key=`categorized/${email.replace("@","_at_")}.json`;await this.s3.putObject({bucket:this.config.bucket,key,body:JSON.stringify({keys:Array.from(categorizedKeys)}),contentType:"application/json"})}catch(err){console.error(`Failed to save categorized keys for ${email}:`,err)}}async loadFlags(email){const cached=this.flagsCache.get(email);if(cached)return cached;try{const flagsKey=`flags/${email.replace("@","_at_")}.json`,content=await this.s3.getObject(this.config.bucket,flagsKey),flags=JSON.parse(content);this.flagsCache.set(email,flags);return flags}catch{const empty={};this.flagsCache.set(email,empty);return empty}}async saveFlags(email){const flags=this.flagsCache.get(email);if(!flags)return;try{const persistableFlags={};for(const[key,flagList]of Object.entries(flags))persistableFlags[key]=flagList.filter((f)=>f!=="\\Recent");const flagsKey=`flags/${email.replace("@","_at_")}.json`;await this.s3.putObject({bucket:this.config.bucket,key:flagsKey,body:JSON.stringify(persistableFlags),contentType:"application/json"});console.log(`Saved flags for ${email}`)}catch(err){console.error(`Failed to save flags for ${email}:`,err)}}async loadUidMapping(email){const cached=this.uidMappingCache.get(email),nextUid=this.nextUidCache.get(email);if(cached&&nextUid!==void 0)return{mapping:cached,nextUid};try{const uidKey=`uids/${email.replace("@","_at_")}.json`,content=await this.s3.getObject(this.config.bucket,uidKey),data=JSON.parse(content),mapping=data.mapping||{},loadedNextUid=data.nextUid||1;this.uidMappingCache.set(email,mapping);this.nextUidCache.set(email,loadedNextUid);return{mapping,nextUid:loadedNextUid}}catch{const empty={};this.uidMappingCache.set(email,empty);this.nextUidCache.set(email,1);return{mapping:empty,nextUid:1}}}async saveUidMapping(email){const mapping=this.uidMappingCache.get(email),nextUid=this.nextUidCache.get(email);if(!mapping)return;try{const uidKey=`uids/${email.replace("@","_at_")}.json`;await this.s3.putObject({bucket:this.config.bucket,key:uidKey,body:JSON.stringify({mapping,nextUid}),contentType:"application/json"})}catch(err){console.error(`Failed to save UID mapping for ${email}:`,err)}}getOrAssignUid(email,s3Key){let mapping=this.uidMappingCache.get(email);if(!mapping){mapping={};this.uidMappingCache.set(email,mapping)}if(mapping[s3Key])return mapping[s3Key];let nextUid=this.nextUidCache.get(email)||1;mapping[s3Key]=nextUid;nextUid++;this.nextUidCache.set(email,nextUid);return mapping[s3Key]}getMessagesForFolder(email,folder){const userCache=this.messageCache.get(email);if(!userCache)return[];return userCache.get(folder.toUpperCase())||[]}setMessagesForFolder(email,folder,messages){let userCache=this.messageCache.get(email);if(!userCache){userCache=new Map;this.messageCache.set(email,userCache)}userCache.set(folder.toUpperCase(),messages)}getUidCounterForFolder(email,folder){const userCounters=this.uidCounter.get(email);if(!userCounters)return 0;return userCounters.get(folder.toUpperCase())||0}setUidCounterForFolder(email,folder,value){let userCounters=this.uidCounter.get(email);if(!userCounters){userCounters=new Map;this.uidCounter.set(email,userCounters)}userCounters.set(folder.toUpperCase(),value)}async handleXlist(session,tag,args){if(session.state==="not_authenticated"){this.send(session,`${tag} NO Must authenticate first`);return}const match=args.match(/^"?([^"]*)"?\s+"?([^"]*)"?$/);if(!match){this.send(session,`${tag} BAD Invalid XLIST syntax`);return}const[,reference="",pattern=""]=match;if(pattern==="*"||pattern==="%"||pattern===""){this.send(session,'* XLIST (\\HasNoChildren \\Inbox) "/" "INBOX"');this.send(session,'* XLIST (\\HasNoChildren \\Sent) "/" "Sent"');this.send(session,'* XLIST (\\HasNoChildren \\Drafts) "/" "Drafts"');this.send(session,'* XLIST (\\HasNoChildren \\Trash) "/" "Trash"');this.send(session,'* XLIST (\\HasNoChildren \\Spam) "/" "Junk"');this.send(session,'* XLIST (\\HasNoChildren \\AllMail) "/" "All Mail"');this.send(session,'* XLIST (\\HasNoChildren) "/" "Archive"')}this.send(session,`${tag} OK XLIST completed`)}async handleCreate(session,tag,args){if(session.state==="not_authenticated"){this.send(session,`${tag} NO Must authenticate first`);return}const mailbox=args.replace(/^"(.*)"$/,"$1");this.send(session,`${tag} OK CREATE completed`)}async handleDelete(session,tag,args){if(session.state==="not_authenticated"){this.send(session,`${tag} NO Must authenticate first`);return}const mailbox=args.replace(/^"(.*)"$/,"$1").toUpperCase();if(["INBOX","SENT","DRAFTS","TRASH","JUNK","ARCHIVE","ALL MAIL"].includes(mailbox)){this.send(session,`${tag} NO Cannot delete system folder`);return}this.send(session,`${tag} OK DELETE completed`)}async handleSubscribe(session,tag,args){if(session.state==="not_authenticated"){this.send(session,`${tag} NO Must authenticate first`);return}this.send(session,`${tag} OK SUBSCRIBE completed`)}async handleUnsubscribe(session,tag,args){if(session.state==="not_authenticated"){this.send(session,`${tag} NO Must authenticate first`);return}this.send(session,`${tag} OK UNSUBSCRIBE completed`)}async handleRename(session,tag,args){if(session.state==="not_authenticated"){this.send(session,`${tag} NO Must authenticate first`);return}const match=args.match(/^"?([^"\s]+)"?\s+"?([^"\s]+)"?$/);if(!match){this.send(session,`${tag} BAD Invalid RENAME syntax`);return}const oldName=(match[1]||"").toUpperCase();if(["INBOX","SENT","DRAFTS","TRASH","JUNK","ARCHIVE","ALL MAIL"].includes(oldName)){this.send(session,`${tag} NO Cannot rename system folder`);return}this.send(session,`${tag} OK RENAME completed`)}async handleAppend(session,tag,args){if(session.state==="not_authenticated"){this.send(session,`${tag} NO Must authenticate first`);return}console.log(`IMAP APPEND [${session.username}]: parsing args: ${args}`);const literalMatch=args.match(/\{(\d+)\}$/);if(!literalMatch){this.send(session,`${tag} BAD APPEND requires literal size`);return}const literalSize=Number.parseInt(literalMatch[1]||"0",10),argsWithoutLiteral=args.substring(0,args.lastIndexOf("{")).trim();let mailbox,rest;if(argsWithoutLiteral.startsWith('"')){const endQuote=argsWithoutLiteral.indexOf('"',1);if(endQuote===-1){this.send(session,`${tag} BAD Invalid mailbox name`);return}mailbox=argsWithoutLiteral.substring(1,endQuote);rest=argsWithoutLiteral.substring(endQuote+1).trim()}else{const spaceIdx=argsWithoutLiteral.indexOf(" ");if(spaceIdx===-1){mailbox=argsWithoutLiteral;rest=""}else{mailbox=argsWithoutLiteral.substring(0,spaceIdx);rest=argsWithoutLiteral.substring(spaceIdx+1).trim()}}let flags=[];const flagsMatch=rest.match(/^\(([^)]*)\)/);if(flagsMatch){flags=(flagsMatch[1]||"").split(/\s+/).filter((f)=>f.length>0);rest=rest.substring(flagsMatch[0].length).trim()}let date;if(rest.startsWith('"')){const endQuote=rest.indexOf('"',1);if(endQuote!==-1)date=rest.substring(1,endQuote)}console.log(`IMAP APPEND [${session.username}]: mailbox=${mailbox}, flags=[${flags.join(", ")}], date=${date}, size=${literalSize}`);session.pendingLiteral={tag,command:"APPEND",args,size:literalSize,data:Buffer.alloc(literalSize),collected:0,mailbox,flags,date};this.send(session,"+ Ready for literal data")}async completeAppend(session,pending){const{tag,mailbox,flags,data}=pending;if(!mailbox){this.send(session,`${tag} NO APPEND failed: no mailbox specified`);return}const prefix=this.getFolderPrefix(mailbox);if(this.isVirtualFolder(mailbox)){this.send(session,`${tag} NO [CANNOT] Cannot APPEND to virtual folder`);return}console.log(`IMAP APPEND [${session.username}]: Saving ${data.length} bytes to ${mailbox} (prefix: ${prefix})`);const messageId=`${Date.now()}-${crypto.randomUUID().substring(0,8)}`,s3Key=`${prefix}${messageId}`;await this.s3.putObject({bucket:this.config.bucket,key:s3Key,body:data.toString("utf-8"),contentType:"message/rfc822"});console.log(`IMAP APPEND [${session.username}]: Saved message to s3://${this.config.bucket}/${s3Key}`);const userKey=session.email?.replace("@","_at_")||session.username||"unknown",uidMapping=await this.loadUidMapping(userKey),uid=uidMapping.nextUid++;uidMapping.mapping[s3Key]=uid;this.nextUidCache.set(userKey,uidMapping.nextUid);await this.saveUidMapping(userKey);if(flags&&flags.length>0){const allFlags=await this.loadFlags(userKey);allFlags[s3Key]=flags;await this.saveFlags(userKey)}const uidValidity=1;console.log(`IMAP APPEND [${session.username}]: Assigned UID ${uid} to message`);this.send(session,`${tag} OK [APPENDUID ${uidValidity} ${uid}] APPEND completed`)}}export async function startImapServer(config){const server=new ImapServer(config);await server.start();return server}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import*as net from"node:net";import*as tls from"node:tls";import*as fs from"node:fs";import*as crypto from"node:crypto";import{S3Client,SESClient}from"@stacksjs/ts-cloud";export class SmtpServer{config;ses;s3;server;tlsServer;sessions=new Map;constructor(config){this.config={port:587,tlsPort:465,host:"0.0.0.0",...config};this.ses=new SESClient(config.region||"us-east-1");if(config.sentBucket)this.s3=new S3Client(config.region||"us-east-1")}async start(){this.server=net.createServer((socket)=>{this.handleConnection(socket,!1)});this.server.listen(this.config.port,this.config.host,()=>{console.log(`SMTP server listening on ${this.config.host}:${this.config.port} (STARTTLS)`)});if(this.config.tls?.key&&this.config.tls?.cert){const tlsOptions={key:fs.readFileSync(this.config.tls.key),cert:fs.readFileSync(this.config.tls.cert)};this.tlsServer=tls.createServer(tlsOptions,(socket)=>{this.handleConnection(socket,!0)});this.tlsServer.listen(this.config.tlsPort,this.config.host,()=>{console.log(`SMTP server listening on ${this.config.host}:${this.config.tlsPort} (TLS)`)})}}async stop(){if(this.server)this.server.close();if(this.tlsServer)this.tlsServer.close();for(const session of this.sessions.values())session.socket.destroy();this.sessions.clear()}handleConnection(socket,secure){const sessionId=crypto.randomUUID(),session={id:sessionId,socket,secure,state:"greeting",authenticated:!1,rcptTo:[],dataBuffer:"",tlsUpgraded:secure};this.sessions.set(sessionId,session);console.log(`SMTP connection from ${socket.remoteAddress} (session: ${sessionId.slice(0,8)})`);this.send(session,`220 ${this.config.domain} ESMTP Mail Server`);session.state="ready";let buffer="";socket.on("data",async(data)=>{buffer+=data.toString();let lineEnd;while((lineEnd=buffer.indexOf(`\r
|
|
2
|
+
`))!==-1){const line=buffer.slice(0,lineEnd);buffer=buffer.slice(lineEnd+2);if(session.state==="data")await this.handleDataLine(session,line);else await this.handleCommand(session,line)}});socket.on("close",()=>{console.log(`SMTP session ${sessionId.slice(0,8)} closed`);this.sessions.delete(sessionId)});socket.on("error",(err)=>{console.error(`SMTP session ${sessionId.slice(0,8)} error:`,err.message);this.sessions.delete(sessionId)})}send(session,message){if(!session.socket.destroyed)session.socket.write(`${message}\r
|
|
3
|
+
`)}async handleCommand(session,line){const trimmedLine=line.trim();if(!trimmedLine)return;const command=trimmedLine.split(" ")[0]?.toUpperCase();if(!command)return;const args=trimmedLine.slice(command.length).trim();console.log(`SMTP CMD [${session.username||"anon"}]: ${command} ${args.includes("AUTH")?"***":args}`);switch(command){case"EHLO":case"HELO":await this.handleEhlo(session,args);break;case"STARTTLS":await this.handleStartTls(session);break;case"AUTH":await this.handleAuth(session,args);break;case"MAIL":await this.handleMailFrom(session,args);break;case"RCPT":await this.handleRcptTo(session,args);break;case"DATA":await this.handleData(session);break;case"RSET":this.resetSession(session);this.send(session,"250 OK");break;case"NOOP":this.send(session,"250 OK");break;case"QUIT":this.send(session,`221 ${this.config.domain} closing connection`);session.socket.end();break;default:this.send(session,"500 Unrecognized command")}}async handleEhlo(session,clientDomain){const capabilities=[`250-${this.config.domain} Hello ${clientDomain}`,"250-SIZE 26214400","250-8BITMIME","250-PIPELINING"];if(!session.secure&&this.config.tls?.key)capabilities.push("250-STARTTLS");capabilities.push("250-AUTH PLAIN LOGIN");capabilities.push("250 OK");for(const cap of capabilities)this.send(session,cap)}async handleStartTls(session){if(session.secure||session.tlsUpgraded){this.send(session,"503 TLS already active");return}if(!this.config.tls?.key||!this.config.tls?.cert){this.send(session,"454 TLS not available");return}this.send(session,"220 Ready to start TLS");const tlsOptions={key:fs.readFileSync(this.config.tls.key),cert:fs.readFileSync(this.config.tls.cert),isServer:!0},tlsSocket=new tls.TLSSocket(session.socket,tlsOptions);session.socket=tlsSocket;session.secure=!0;session.tlsUpgraded=!0;this.resetSession(session)}async handleAuth(session,args){const parts=args.split(" "),mechanism=parts[0]?.toUpperCase();if(!mechanism){this.send(session,"504 Authentication mechanism required");return}if(mechanism==="PLAIN")if(parts[1])await this.handleAuthPlain(session,parts[1]);else{this.send(session,"334 ");session.state="ready"}else if(mechanism==="LOGIN"){this.send(session,"334 VXNlcm5hbWU6");const originalHandler=session.socket.listeners("data")[0];session.socket.removeAllListeners("data");let step="username",username="";session.socket.on("data",async(data)=>{try{const line=data.toString().trim();if(step==="username"){username=Buffer.from(line,"base64").toString("utf-8");step="password";this.send(session,"334 UGFzc3dvcmQ6")}else if(step==="password"){const password=Buffer.from(line,"base64").toString("utf-8");session.socket.removeAllListeners("data");session.socket.on("data",originalHandler);await this.authenticateUser(session,username,password)}}catch(err){session.socket.removeAllListeners("data");session.socket.on("data",originalHandler);this.send(session,"535 Authentication failed");console.error(`SMTP AUTH LOGIN error: ${err.message}`)}})}else this.send(session,"504 Unrecognized authentication mechanism")}async handleAuthPlain(session,credentials){try{const parts=Buffer.from(credentials,"base64").toString("utf-8").split("\x00"),username=parts.length===3?parts[1]:parts[0],password=parts.length===3?parts[2]:parts[1];if(!username||password===void 0)throw Error("Malformed AUTH PLAIN credentials");await this.authenticateUser(session,username,password)}catch(err){this.send(session,"535 Authentication failed")}}async authenticateUser(session,username,password){const cleanUsername=username.includes("@")?username.split("@")[0]??"":username,user=this.config.users[cleanUsername],passwordsMatch=user?(()=>{try{const expected=Buffer.from(user.password,"utf-8"),actual=Buffer.from(password,"utf-8");if(expected.length!==actual.length){const maxLen=Math.max(expected.length,actual.length),a=Buffer.alloc(maxLen),b=Buffer.alloc(maxLen);a.set(expected);b.set(actual);crypto.timingSafeEqual(a,b);return!1}return crypto.timingSafeEqual(expected,actual)}catch{return!1}})():!1;if(user&&passwordsMatch){session.authenticated=!0;session.username=cleanUsername;session.email=user.email;this.send(session,"235 Authentication successful");console.log(`SMTP AUTH success: ${cleanUsername}`)}else{this.send(session,"535 Authentication failed");console.log(`SMTP AUTH failed: ${username}`)}}async handleMailFrom(session,args){if(!session.authenticated){this.send(session,"530 Authentication required");return}const match=args.match(/FROM:\s*<([^>]*)>/i);if(!match){this.send(session,"501 Syntax error in MAIL FROM");return}const fromAddress=match[1];if(!fromAddress){this.send(session,"501 Syntax error in MAIL FROM");return}if(!fromAddress.endsWith(`@${this.config.domain}`)){this.send(session,`553 Sender address must be from @${this.config.domain}`);return}session.mailFrom=fromAddress;session.state="mail";this.send(session,"250 OK")}async handleRcptTo(session,args){if(!session.authenticated){this.send(session,"530 Authentication required");return}if(!session.mailFrom){this.send(session,"503 MAIL FROM required first");return}const match=args.match(/TO:\s*<([^>]*)>/i);if(!match){this.send(session,"501 Syntax error in RCPT TO");return}const recipient=match[1];if(!recipient){this.send(session,"501 Syntax error in RCPT TO");return}session.rcptTo.push(recipient);session.state="rcpt";this.send(session,"250 OK")}async handleData(session){if(!session.authenticated){this.send(session,"530 Authentication required");return}if(session.rcptTo.length===0){this.send(session,"503 RCPT TO required first");return}session.state="data";session.dataBuffer="";this.send(session,"354 Start mail input; end with <CRLF>.<CRLF>")}async handleDataLine(session,line){if(line===".")await this.sendEmail(session);else{const actualLine=line.startsWith(".")?line.slice(1):line;session.dataBuffer+=`${actualLine}\r
|
|
4
|
+
`}}async sendEmail(session){try{const rawEmail=session.dataBuffer,result=await this.ses.sendRawEmail({source:session.mailFrom,destinations:session.rcptTo,rawMessage:rawEmail});console.log(`SMTP: Email sent via SES, MessageId: ${result.MessageId}`);if(this.s3&&this.config.sentBucket)try{const sentKey=`${this.config.sentPrefix||"sent/"}${session.email}/${Date.now()}-${result.MessageId}`;await this.s3.putObject({bucket:this.config.sentBucket,key:sentKey,body:rawEmail,contentType:"message/rfc822"});console.log(`SMTP: Email stored in S3: ${sentKey}`)}catch(s3Err){console.error("SMTP: Failed to store sent email in S3 (email was sent successfully):",s3Err.message)}this.send(session,`250 OK Message accepted, ID: ${result.MessageId}`);this.resetSession(session)}catch(err){console.error("SMTP: Failed to send email:",err.message);this.send(session,`451 Failed to send: ${err.message}`);this.resetSession(session)}}resetSession(session){session.state="ready";session.mailFrom=void 0;session.rcptTo=[];session.dataBuffer=""}}export async function startSmtpServer(config){const server=new SmtpServer(config);await server.start();return server}
|
package/dist/index.d.ts
CHANGED
|
@@ -29,8 +29,12 @@ export type {
|
|
|
29
29
|
} from '@stacksjs/ts-cloud/aws';
|
|
30
30
|
export * from './cloud/index';
|
|
31
31
|
export * from './helpers';
|
|
32
|
-
|
|
33
|
-
*
|
|
32
|
+
/**
|
|
33
|
+
* The mail servers, and the AWS clients `@stacksjs/buddy`'s mail commands use.
|
|
34
|
+
*
|
|
35
|
+
* Keep the clients available from the root. Module-preserving builds also
|
|
36
|
+
* publish the local mail server subpaths, but the removed `imap/s3` wrapper
|
|
37
|
+
* has no source module. Import the client from here or from ts-cloud.
|
|
34
38
|
*
|
|
35
39
|
* The clients are ts-cloud's own, under the names this package has always
|
|
36
40
|
* exported. They used to be vendored copies under `imap/` that drifted: the
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1 @@
|
|
|
1
|
-
|
|
2
|
-
import{config as V}from"@stacksjs/config";import{InfrastructureGenerator as K}from"@stacksjs/ts-cloud";class z{generator;cloudConfig;props;constructor(e,t){this.cloudConfig=e,this.props=t;let n=(t.appEnv==="local"?"development":t.appEnv)||"production";this.generator=new K({config:this.cloudConfig,environment:n})}generate(){return this.generator.generate().toJSON()}shouldDeployApi(){return V.cloud.api?.deploy??!1}}import{existsSync as J}from"fs";import A from"process";import{AWSClient as M,AWSCloudFormationClient as T,CloudWatchLogsClient as q,EC2Client as x,IAMClient as G,LambdaClient as _,Route53DomainsClient as W,S3Client as F,SSMClient as D}from"@stacksjs/ts-cloud";import{config as w}from"@stacksjs/config";import{err as p,handleError as h,ok as c}from"@stacksjs/error-handling";import{log as g}from"@stacksjs/logging";import{path as L}from"@stacksjs/path";import{slug as H}from"@stacksjs/strings";async function b(){let e=L.projectPath("config/cloud.ts");if(!J(e))return{};return(await import(e)).tsCloud??{}}function Z(){return A.env.APP_ENV||A.env.NODE_ENV||"production"}async function N(e){let{project:t}=e??await b();return`${t?.name||"stacks"}-cloud`}async function S(e,t=Z()){let{project:n,environments:r}=e??await b();return A.env.AWS_REGION||r?.[t]?.region||n?.region||"us-east-1"}async function I(e,t,n={}){let r=new M,a={Action:t,Version:"2016-11-15",...n};return await r.request({service:"ec2",region:e,method:"POST",path:"/",queryParams:a})}async function X(e,t,n){return await new M().request({service:"logs",region:e,method:"POST",path:"/",headers:{"Content-Type":"application/x-amz-json-1.1","X-Amz-Target":`Logs_20140328.${t}`},body:JSON.stringify(n)})}async function fe(e){let t=new x(await S()),{SecurityGroups:n}=await t.describeSecurityGroups({Filters:[{Name:"group-name",Values:[e]}]});if(!n)return p(`Security group ${e} not found`);if(n[0])return c(n[0].GroupId);return p(`Security group ${e} not found`)}function he(e){let t=e.admin??{},n=e.tech??{},r=e.privacy??!0,a=String(e.contactType||"PERSON").toUpperCase();return{domain:"",years:1,privacy:r,autoRenew:!0,registrantFirstName:e.firstName??"",registrantLastName:e.lastName??"",registrantOrganization:e.organizationName??"",registrantAddressLine1:e.addressLine1??"",registrantAddressLine2:e.addressLine2??"",registrantCity:e.city??"",registrantState:e.state??"",registrantCountry:e.countryCode,registrantZip:e.zip??"",registrantPhone:e.phoneNumber??"",registrantEmail:e.email??"",adminFirstName:t.firstName||e.firstName||"",adminLastName:t.lastName||e.lastName||"",adminOrganization:t.organizationName||e.organizationName||"",adminAddressLine1:t.addressLine1||e.addressLine1||"",adminAddressLine2:t.addressLine2||e.addressLine2||"",adminCity:t.city||e.city||"",adminState:t.state||e.state||"",adminCountry:t.countryCode||e.countryCode,adminZip:t.zip||e.zip||"",adminPhone:t.phoneNumber||e.phoneNumber||"",adminEmail:t.email||e.email||"",techFirstName:n.firstName||e.firstName||"",techLastName:n.lastName||e.lastName||"",techOrganization:n.organizationName||e.organizationName||"",techAddressLine1:n.addressLine1||e.addressLine1||"",techAddressLine2:n.addressLine2||e.addressLine2||"",techCity:n.city||e.city||"",techState:n.state||e.state||"",techCountry:n.countryCode||e.countryCode,techZip:n.zip||e.zip||"",techPhone:n.phoneNumber||e.phoneNumber||"",techEmail:n.email||e.email||"",privacyAdmin:e.privacyAdmin??r,privacyTech:e.privacyTech??r,privacyRegistrant:e.privacyRegistrant??r,contactType:a,verbose:!1}}var Q={firstName:"registrantFirstName",lastName:"registrantLastName",organization:"registrantOrganization",addressLine1:"registrantAddressLine1",addressLine2:"registrantAddressLine2",city:"registrantCity",state:"registrantState",country:"registrantCountry",zip:"registrantZip",phone:"registrantPhone",email:"registrantEmail"};function ye(e,t){let n={...e};for(let[a,i]of Object.entries(t)){if(i===void 0||i===null||i==="")continue;n[Q[a]??a]=i}for(let a of["privacy","autoRenew","privacyAdmin","privacyTech","privacyRegistrant","verbose"])if(n[a]==="false")n[a]=!1;else if(n[a]==="true")n[a]=!0;if(t.privacy!==void 0){for(let a of["privacyAdmin","privacyTech","privacyRegistrant"])if(t[a]===void 0)n[a]=n.privacy}let r=Number(n.years);if(n.years=Number.isInteger(r)&&r>0?r:1,typeof n.contactType==="string")n.contactType=n.contactType.toUpperCase();return n}async function Se(e,t,n=new W){let r=String(t.contactType||"PERSON").toUpperCase(),a=(s)=>s.toString().includes("+")?s.toString():`+${s.toString()}`,i=(s)=>({FirstName:t[`${s}FirstName`],LastName:t[`${s}LastName`],ContactType:r,OrganizationName:t[`${s}Organization`],AddressLine1:t[`${s}AddressLine1`],AddressLine2:t[`${s}AddressLine2`],City:t[`${s}City`],State:t[`${s}State`],CountryCode:t[`${s}Country`],ZipCode:String(t[`${s}Zip`]??""),PhoneNumber:a(t[`${s}Phone`]??""),Email:t[`${s}Email`]});try{let s=await n.registerDomain({DomainName:e,DurationInYears:t.years||1,AutoRenew:t.autoRenew??!0,AdminContact:i("admin"),RegistrantContact:i("registrant"),TechContact:i("tech"),PrivacyProtectAdminContact:t.privacyAdmin??t.privacy??!0,PrivacyProtectRegistrantContact:t.privacyRegistrant??t.privacy??!0,PrivacyProtectTechContact:t.privacyTech??t.privacy??!0});if(!s?.OperationId)return p(Error(`Route 53 returned no operation id for ${e}, so the registration cannot be confirmed.`));return c({OperationId:s.OperationId})}catch(s){return p(s instanceof Error?s:Error(String(s)))}}var Y="JumpBoxInstanceId",ee="Set `infrastructure.jumpBox: true` in the `tsCloud` export of config/cloud.ts (or an object: `{ size, keyName, allowedCidrs, databaseTools, mountEfs }`), then run `buddy deploy`.";function te(e){let t=e.infrastructure?.jumpBox;if(!t)return!1;return t===!0||t.enabled!==!1}async function B(e={}){let t=e.config??await b(),n=await N(t),r=await S(t),a=(e.cloudFormation??((s)=>new T(s)))(r),i={stack:n,region:r,declared:te(t),deployed:!1};try{let{Stacks:s}=await a.describeStacks({stackName:n}),o=s?.[0];if(!o||o.StackStatus==="DELETE_COMPLETE")return c(i);return i.deployed=!0,i.instanceId=o.Outputs?.find((d)=>d.OutputKey===Y)?.OutputValue||void 0,c(i)}catch(s){let o=s instanceof Error?s:Error(String(s));if(/does not exist/i.test(o.message))return c(i);return p(o)}}async function ke(e={}){let t=await B(e);if(t.isErr)throw t.error;return t.value.instanceId}function C(e,t,n){return{done:e,state:t,message:n}}function P(e){return`the ${e.stack} stack (${e.region})`}async function we(e={}){let t=await B(e);if(t.isErr)return p(`Could not read the jump box from the deployed stack: ${t.error.message}`);let n=t.value;if(n.instanceId)return c(C(!0,n,`${P(n)} already has a jump box, ${n.instanceId}. Connect with \`buddy cloud --ssh\`.`));if(n.declared){let r=n.deployed?"has not been deployed with it yet":"is not deployed";return c(C(!1,n,`config/cloud.ts declares a jump box, but ${P(n)} ${r}. Run \`buddy deploy\` to create it.`))}return c(C(!1,n,`${P(n)} has no jump box. ${ee}`))}async function Ce(e={}){let t=await B(e);if(t.isErr)return p(`Could not read the jump box from the deployed stack: ${t.error.message}`);let n=t.value;if(!n.instanceId){let r=n.declared?" config/cloud.ts still declares one, so the next `buddy deploy` creates it; remove `infrastructure.jumpBox` to keep it gone.":"";return c(C(!0,n,`${P(n)} has no jump box.${r}`))}if(n.declared)return c(C(!1,n,`${P(n)} has a jump box, ${n.instanceId}. Remove \`infrastructure.jumpBox\` from the \`tsCloud\` export of config/cloud.ts (or set \`enabled: false\`), then run \`buddy deploy\`; CloudFormation deletes the instance, its security group and its role.`));return c(C(!1,n,`${P(n)} still has a jump box, ${n.instanceId}, though config/cloud.ts no longer declares one. Run \`buddy deploy\` to remove it.`))}async function Pe(){let e=new G("us-east-1"),t=await e.listUsers(),n=H(w.team.name),r=t.Users?.filter((i)=>{let o=i.UserName?.toLowerCase();return o!=="stacks"&&o!==n.toLowerCase()&&o?.includes(n.toLowerCase())})||[];if(!r||r.length===0)return c(`No Stacks IAM users found for team ${n}`);let a=r.map(async(i)=>{let o=i.UserName||"";g.info(`Deleting IAM user: ${o}`);let d=await e.listAttachedUserPolicies({UserName:o});await Promise.all(d.AttachedPolicies?.map((l)=>{let y=l;return e.detachUserPolicy({UserName:o,PolicyArn:y.PolicyArn||""})})||[]);let u=await e.listAccessKeys({UserName:o});return await Promise.all(u.AccessKeyMetadata?.map((l)=>{let y=l;return e.deleteAccessKey({UserName:o,AccessKeyId:y.AccessKeyId||""})})||[]),e.deleteUser({UserName:o})});try{await Promise.all(a)}catch(i){return console.error(i),p(h("Error deleting Stacks IAM users").message)}return c(`Stacks IAM users deleted for team ${n}`)}async function be(e={}){try{let t=(e.client??((i)=>new F(i)))(await S(e.config)),r=(await t.listBuckets()).Buckets?.filter((i)=>i.Name?.includes("stacks"));if(!r)return p("No stacks buckets found");let a=r.map(async(i)=>{let s=i.Name||"";g.info(`Deleting bucket ${s}...`);let o,d=!0;while(d){let u=await t.listObjects({bucket:s,continuationToken:o});if(u.objects&&u.objects.length>0)g.info(`Deleting ${u.objects.length} objects from bucket ${s}...`),await Promise.all(u.objects.map((l)=>{let y=l;return t.deleteObject(s,y.Key||"")})).catch((l)=>{throw g.error(`Failed to delete objects from bucket ${s}:`,l),l});d=!!u.nextContinuationToken,o=u.nextContinuationToken}g.info(`Finished deleting objects from bucket ${s}`),g.info(`Deleting bucket ${s} versions...`);try{let u,l,y=!0;while(y){let m=await t.listObjectVersions({bucket:s,keyMarker:u,versionIdMarker:l});if(m.versions&&m.versions.length>0)await Promise.all(m.versions.map((f)=>{let O=f;return t.deleteObject(s,O.Key||"")})).catch((f)=>h(f)),g.info(`Deleted ${m.versions.length} versions from bucket ${s}`);if(m.deleteMarkers&&m.deleteMarkers.length>0)await Promise.all(m.deleteMarkers.map((f)=>{let O=f;return t.deleteObject(s,O.Key||"")})).catch((f)=>h(f)),g.info(`Deleted ${m.deleteMarkers.length} delete markers from bucket ${s}`);y=!!m.nextKeyMarker,u=m.nextKeyMarker,l=m.nextVersionIdMarker}g.info(`Finished deleting all versions from bucket ${s}`);let k=await t.listMultipartUploads(s);if(k&&k.length>0)g.info("Aborting bucket multipart uploads..."),await Promise.all(k.map((m)=>{let f=m;return t.abortMultipartUpload(s,f.Key||"",f.UploadId||"")})).catch((m)=>h(m)),g.info(`Finished aborting multipart uploads from bucket ${s}`);await t.deleteBucket(s).catch((m)=>h(m)),g.info(`Bucket ${s} deleted`)}catch(u){g.info(`Error listing bucket ${s} versions`,u)}});return await Promise.all(a).catch((i)=>(console.error(i),p(h("Error deleting stacks buckets")))),c("Stacks buckets deleted")}catch(t){return p(h("Error deleting stacks buckets",t))}}async function ve(e={}){let t=e.client??((a)=>new _(a)),n=[...new Set([await S(e.config),"us-east-1"])],r=[];for(let a of n){let i=t(a),s=await i.listFunctions();for(let o of s.Functions??[]){let d=o.FunctionName;if(d?.includes("stacks"))r.push({lambda:i,name:d})}}if(r.length===0)return c("No stacks functions found");try{await Promise.all(r.map(({lambda:a,name:i})=>a.deleteFunction(i)))}catch(a){let i=a;if(i.message.includes("it is a replicated function"))return g.info("Function is replicated, skipping..."),c("CloudFront is still deleting the some functions. Try again later.");return p(h("Error deleting stacks functions",i).message)}return c("Stacks functions deleted")}async function Re(){try{let e=await I(await S(),"DescribeRegions"),n=e.regionInfo?.item??e.Regions??e.regionSet??[],r=[];if(Array.isArray(n))for(let a of n){let i=a.regionName??a.RegionName;if(i)r.push(i)}for(let a of r){let s=await new q(a).describeLogGroups();if(s?.logGroups)for(let o of s.logGroups){let d=w.app.name?.toLocaleLowerCase()||"stacks";if(o.logGroupName?.includes(d))await X(a,"DeleteLogGroup",{logGroupName:o.logGroupName})}}return c("Log groups deleted in all regions")}catch(e){return p(h("Error deleting log groups",e))}}async function Te(e={}){let t=(e.client??((s)=>new D(s)))(await S(e.config)),n=await t.describeParameters();if(!n.Parameters)return c("No parameters found");let r=w.app.name?.toLocaleLowerCase()||"stacks",a=n.Parameters.filter((s)=>s.Name?.includes(r))||[];if(!a||a.length===0)return c("No stacks parameters found");let i=a.map((s)=>{let o=s;return t.deleteParameter({Name:o.Name||""})});try{await Promise.all(i)}catch(s){return p(h("Error deleting parameter store",s).message)}return c("Parameter store deleted")}async function Ne(e={}){let t=await S(e.config),n=(e.client??((i)=>new x(i)))(t),r=e.request??I,a=w.app.name?`${w.app.name.toLowerCase()}-`:"stacks-";try{let{Vpcs:i}=await n.describeVpcs();if(!i||i.length===0)return c("No VPCs found");let s=i.filter((o)=>o.Tags?.some((l)=>l.Key==="Name"&&l.Value===a));if(s.length===0)return c(`No VPCs found matching the pattern: ${a}`);for(let o of s)if(o.VpcId)await r(t,"DeleteVpc",{VpcId:o.VpcId}),g.info(`Deleted VPC: ${o.VpcId} (${a})`);return c(`Deleted ${s.length} VPCs matching the pattern: ${a}`)}catch(i){return p(h(`Error deleting VPCs: ${i}`))}}async function Ee(){try{return await Bun.$`rm -rf ${L.cloudPath("cdk.out/")} ${L.cloudPath("cdk.context.json")}`.text(),c("CDK remnants deleted")}catch(e){return p(h("Error deleting CDK remnants",e))}}async function Oe(e={}){let t=await S(e.config),n=(e.client??((i)=>new x(i)))(t),r=e.request??I,a=w.app.name?`${w.app.name.toLowerCase()}-`:"stacks-";try{let{Subnets:i}=await n.describeSubnets();if(!i||i.length===0)return c("No subnets found");let s=i.filter((o)=>o.Tags?.some((l)=>l.Key==="Name"&&l.Value?.startsWith(a)));if(s.length===0)return c(`No subnets found matching the pattern: ${a}`);for(let o of s)if(o.SubnetId){let l=(await r(t,"DescribeNetworkInterfaces",{"Filter.1.Name":"subnet-id","Filter.1.Value.1":o.SubnetId})).networkInterfaceSet?.item??[];for(let y of Array.isArray(l)?l:[]){let k=y.networkInterfaceId;if(k){let m=y.attachment;if(m?.instanceId)await n.terminateInstances([m.instanceId]),g.info(`Terminated instance: ${m.instanceId}`),await new Promise((f)=>setTimeout(f,60000));if(m?.attachmentId)await r(t,"DetachNetworkInterface",{AttachmentId:m.attachmentId,Force:"true"}),g.info(`Detached network interface: ${k}`),await new Promise((f)=>setTimeout(f,1e4));await r(t,"DeleteNetworkInterface",{NetworkInterfaceId:k}),g.info(`Deleted network interface: ${k}`)}}await r(t,"DeleteSubnet",{SubnetId:o.SubnetId}),g.info(`Deleted subnet: ${o.SubnetId} (${o.Tags?.find((y)=>y.Key==="Name")?.Value})`)}return c(`Deleted ${s.length} subnets matching the pattern: ${a}`)}catch(i){return p(h(`Error deleting subnets: ${i}`))}}async function Ae(e={}){let t=(e.client??((n)=>new F(n)))(await S(e.config));try{let n=await t.listBuckets();return c(n.Buckets?.some((r)=>r.Name?.includes(w.app.name?.toLocaleLowerCase()||"stacks"))||!1)}catch(n){return console.error(n),p(h("Error checking if the app has been deployed"))}}async function Le(){let e=await b(),t=await N(e);return!(await new T(await S(e)).listStacks(["CREATE_COMPLETE","UPDATE_COMPLETE"])).StackSummaries?.some((i)=>i.StackName===t)}async function xe(){let e=await b(),t=await N(e),r=await new T(await S(e)).listStacks(["CREATE_FAILED","UPDATE_FAILED","ROLLBACK_COMPLETE","UPDATE_ROLLBACK_COMPLETE"]);return Boolean(r.StackSummaries?.some((a)=>a.StackName===t))}async function Ie(e,t={}){let n=t.config??await b(),r=await N(n),a=await S(n),i=(t.client??((u)=>new T(u)))(a),s;try{s=(await i.getTemplate(r)).TemplateBody}catch(u){let l=u instanceof Error?u:Error(String(u));if(!/does not exist/i.test(l.message))return p(l)}let o=typeof s==="string",d=!0;if(o)try{d=!Bun.deepEquals(JSON.parse(s),JSON.parse(e))}catch{d=s!==e}return c({stack:r,region:a,deployed:o,changed:d,deployedBytes:o?Buffer.byteLength(s):0,localBytes:Buffer.byteLength(e)})}async function $e(e={}){let n=(e.client??((r)=>new D(r)))(await S(e.config));try{let r=await n.getParameter({Name:"/stacks/timestamp"}),a=r.Parameter?r.Parameter.Value:void 0;if(!a)throw Error("Timestamp parameter not found");return a}catch(r){let a=new Date().getTime().toString();return g.debug(`Creating timestamp parameter /stacks/timestamp with value ${a}`,r),await n.putParameter({Name:"/stacks/timestamp",Value:a,Type:"String"}),a}}async function Be(){return""}import*as U from"net";import*as E from"tls";import*as v from"fs";import*as R from"crypto";import{S3Client as ne,SESClient as re}from"@stacksjs/ts-cloud";class j{config;ses;s3;server;tlsServer;sessions=new Map;constructor(e){if(this.config={port:587,tlsPort:465,host:"0.0.0.0",...e},this.ses=new re(e.region||"us-east-1"),e.sentBucket)this.s3=new ne(e.region||"us-east-1")}async start(){if(this.server=U.createServer((e)=>{this.handleConnection(e,!1)}),this.server.listen(this.config.port,this.config.host,()=>{console.log(`SMTP server listening on ${this.config.host}:${this.config.port} (STARTTLS)`)}),this.config.tls?.key&&this.config.tls?.cert){let e={key:v.readFileSync(this.config.tls.key),cert:v.readFileSync(this.config.tls.cert)};this.tlsServer=E.createServer(e,(t)=>{this.handleConnection(t,!0)}),this.tlsServer.listen(this.config.tlsPort,this.config.host,()=>{console.log(`SMTP server listening on ${this.config.host}:${this.config.tlsPort} (TLS)`)})}}async stop(){if(this.server)this.server.close();if(this.tlsServer)this.tlsServer.close();for(let e of this.sessions.values())e.socket.destroy();this.sessions.clear()}handleConnection(e,t){let n=R.randomUUID(),r={id:n,socket:e,secure:t,state:"greeting",authenticated:!1,rcptTo:[],dataBuffer:"",tlsUpgraded:t};this.sessions.set(n,r),console.log(`SMTP connection from ${e.remoteAddress} (session: ${n.slice(0,8)})`),this.send(r,`220 ${this.config.domain} ESMTP Mail Server`),r.state="ready";let a="";e.on("data",async(i)=>{a+=i.toString();let s;while((s=a.indexOf(`\r
|
|
3
|
-
`))!==-1){let o=a.slice(0,s);if(a=a.slice(s+2),r.state==="data")await this.handleDataLine(r,o);else await this.handleCommand(r,o)}}),e.on("close",()=>{console.log(`SMTP session ${n.slice(0,8)} closed`),this.sessions.delete(n)}),e.on("error",(i)=>{console.error(`SMTP session ${n.slice(0,8)} error:`,i.message),this.sessions.delete(n)})}send(e,t){if(!e.socket.destroyed)e.socket.write(`${t}\r
|
|
4
|
-
`)}async handleCommand(e,t){let n=t.trim();if(!n)return;let r=n.split(" ")[0]?.toUpperCase();if(!r)return;let a=n.slice(r.length).trim();switch(console.log(`SMTP CMD [${e.username||"anon"}]: ${r} ${a.includes("AUTH")?"***":a}`),r){case"EHLO":case"HELO":await this.handleEhlo(e,a);break;case"STARTTLS":await this.handleStartTls(e);break;case"AUTH":await this.handleAuth(e,a);break;case"MAIL":await this.handleMailFrom(e,a);break;case"RCPT":await this.handleRcptTo(e,a);break;case"DATA":await this.handleData(e);break;case"RSET":this.resetSession(e),this.send(e,"250 OK");break;case"NOOP":this.send(e,"250 OK");break;case"QUIT":this.send(e,`221 ${this.config.domain} closing connection`),e.socket.end();break;default:this.send(e,"500 Unrecognized command")}}async handleEhlo(e,t){let n=[`250-${this.config.domain} Hello ${t}`,"250-SIZE 26214400","250-8BITMIME","250-PIPELINING"];if(!e.secure&&this.config.tls?.key)n.push("250-STARTTLS");n.push("250-AUTH PLAIN LOGIN"),n.push("250 OK");for(let r of n)this.send(e,r)}async handleStartTls(e){if(e.secure||e.tlsUpgraded){this.send(e,"503 TLS already active");return}if(!this.config.tls?.key||!this.config.tls?.cert){this.send(e,"454 TLS not available");return}this.send(e,"220 Ready to start TLS");let t={key:v.readFileSync(this.config.tls.key),cert:v.readFileSync(this.config.tls.cert),isServer:!0},n=new E.TLSSocket(e.socket,t);e.socket=n,e.secure=!0,e.tlsUpgraded=!0,this.resetSession(e)}async handleAuth(e,t){let n=t.split(" "),r=n[0]?.toUpperCase();if(!r){this.send(e,"504 Authentication mechanism required");return}if(r==="PLAIN")if(n[1])await this.handleAuthPlain(e,n[1]);else this.send(e,"334 "),e.state="ready";else if(r==="LOGIN"){this.send(e,"334 VXNlcm5hbWU6");let a=e.socket.listeners("data")[0];e.socket.removeAllListeners("data");let i="username",s="";e.socket.on("data",async(o)=>{try{let d=o.toString().trim();if(i==="username")s=Buffer.from(d,"base64").toString("utf-8"),i="password",this.send(e,"334 UGFzc3dvcmQ6");else if(i==="password"){let u=Buffer.from(d,"base64").toString("utf-8");e.socket.removeAllListeners("data"),e.socket.on("data",a),await this.authenticateUser(e,s,u)}}catch(d){e.socket.removeAllListeners("data"),e.socket.on("data",a),this.send(e,"535 Authentication failed"),console.error(`SMTP AUTH LOGIN error: ${d.message}`)}})}else this.send(e,"504 Unrecognized authentication mechanism")}async handleAuthPlain(e,t){try{let r=Buffer.from(t,"base64").toString("utf-8").split("\x00"),a=r.length===3?r[1]:r[0],i=r.length===3?r[2]:r[1];if(!a||i===void 0)throw Error("Malformed AUTH PLAIN credentials");await this.authenticateUser(e,a,i)}catch(n){this.send(e,"535 Authentication failed")}}async authenticateUser(e,t,n){let r=t.includes("@")?t.split("@")[0]??"":t,a=this.config.users[r],i=a?(()=>{try{let s=Buffer.from(a.password,"utf-8"),o=Buffer.from(n,"utf-8");if(s.length!==o.length){let d=Math.max(s.length,o.length),u=Buffer.alloc(d),l=Buffer.alloc(d);return u.set(s),l.set(o),R.timingSafeEqual(u,l),!1}return R.timingSafeEqual(s,o)}catch{return!1}})():!1;if(a&&i)e.authenticated=!0,e.username=r,e.email=a.email,this.send(e,"235 Authentication successful"),console.log(`SMTP AUTH success: ${r}`);else this.send(e,"535 Authentication failed"),console.log(`SMTP AUTH failed: ${t}`)}async handleMailFrom(e,t){if(!e.authenticated){this.send(e,"530 Authentication required");return}let n=t.match(/FROM:\s*<([^>]*)>/i);if(!n){this.send(e,"501 Syntax error in MAIL FROM");return}let r=n[1];if(!r){this.send(e,"501 Syntax error in MAIL FROM");return}if(!r.endsWith(`@${this.config.domain}`)){this.send(e,`553 Sender address must be from @${this.config.domain}`);return}e.mailFrom=r,e.state="mail",this.send(e,"250 OK")}async handleRcptTo(e,t){if(!e.authenticated){this.send(e,"530 Authentication required");return}if(!e.mailFrom){this.send(e,"503 MAIL FROM required first");return}let n=t.match(/TO:\s*<([^>]*)>/i);if(!n){this.send(e,"501 Syntax error in RCPT TO");return}let r=n[1];if(!r){this.send(e,"501 Syntax error in RCPT TO");return}e.rcptTo.push(r),e.state="rcpt",this.send(e,"250 OK")}async handleData(e){if(!e.authenticated){this.send(e,"530 Authentication required");return}if(e.rcptTo.length===0){this.send(e,"503 RCPT TO required first");return}e.state="data",e.dataBuffer="",this.send(e,"354 Start mail input; end with <CRLF>.<CRLF>")}async handleDataLine(e,t){if(t===".")await this.sendEmail(e);else{let n=t.startsWith(".")?t.slice(1):t;e.dataBuffer+=`${n}\r
|
|
5
|
-
`}}async sendEmail(e){try{let t=e.dataBuffer,n=await this.ses.sendRawEmail({source:e.mailFrom,destinations:e.rcptTo,rawMessage:t});if(console.log(`SMTP: Email sent via SES, MessageId: ${n.MessageId}`),this.s3&&this.config.sentBucket)try{let r=`${this.config.sentPrefix||"sent/"}${e.email}/${Date.now()}-${n.MessageId}`;await this.s3.putObject({bucket:this.config.sentBucket,key:r,body:t,contentType:"message/rfc822"}),console.log(`SMTP: Email stored in S3: ${r}`)}catch(r){console.error("SMTP: Failed to store sent email in S3 (email was sent successfully):",r.message)}this.send(e,`250 OK Message accepted, ID: ${n.MessageId}`),this.resetSession(e)}catch(t){console.error("SMTP: Failed to send email:",t.message),this.send(e,`451 Failed to send: ${t.message}`),this.resetSession(e)}}resetSession(e){e.state="ready",e.mailFrom=void 0,e.rcptTo=[],e.dataBuffer=""}}async function De(e){let t=new j(e);return await t.start(),t}import{AWSClient as qe,S3Client as Ge,SecretsManagerClient as _e}from"@stacksjs/ts-cloud";import{buildQueryParams as He}from"@stacksjs/ts-cloud/aws";export{qe as AWSClient,z as Cloud,Y as JUMP_BOX_OUTPUT,Ge as S3Client,_e as SecretsManagerClient,j as SmtpServer,we as addJumpBox,ye as applyPurchaseFlags,He as buildQueryParams,Z as cloudEnvironment,te as declaresJumpBox,Ee as deleteCdkRemnants,Pe as deleteIamUsers,Ce as deleteJumpBox,Re as deleteLogGroups,Te as deleteParameterStore,be as deleteStacksBuckets,ve as deleteStacksFunctions,Oe as deleteSubnets,Ne as deleteVpcs,Ie as diffStackTemplate,Be as getCloudFrontDistributionId,ke as getJumpBoxInstanceId,B as getJumpBoxState,$e as getOrCreateTimestamp,fe as getSecurityGroupId,Ae as hasBeenDeployed,xe as isFailedState,Le as isFirstDeployment,b as loadCloudStackConfig,Se as purchaseDomain,he as purchaseOptionsFromContactInfo,N as stacksCloudName,S as stacksCloudRegion,De as startSmtpServer};
|
|
1
|
+
export*from"./cloud";export*from"./helpers";export*from"./imap/smtp-server";export{AWSClient,S3Client,SecretsManagerClient}from"@stacksjs/ts-cloud";export{buildQueryParams}from"@stacksjs/ts-cloud/aws";
|
package/dist/types.js
ADDED
|
File without changes
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@stacksjs/cloud",
|
|
3
3
|
"type": "module",
|
|
4
4
|
"sideEffects": false,
|
|
5
|
-
"version": "0.75.
|
|
5
|
+
"version": "0.75.110",
|
|
6
6
|
"description": "The Stacks cloud/serverless integration & implementation.",
|
|
7
7
|
"author": "Chris Breuer",
|
|
8
8
|
"contributors": [
|
|
@@ -59,22 +59,22 @@
|
|
|
59
59
|
"prepublishOnly": "bun run build"
|
|
60
60
|
},
|
|
61
61
|
"dependencies": {
|
|
62
|
-
"@stacksjs/config": "0.75.
|
|
63
|
-
"@stacksjs/error-handling": "0.75.
|
|
64
|
-
"@stacksjs/logging": "0.75.
|
|
65
|
-
"@stacksjs/path": "0.75.
|
|
66
|
-
"@stacksjs/strings": "0.75.
|
|
62
|
+
"@stacksjs/config": "0.75.110",
|
|
63
|
+
"@stacksjs/error-handling": "0.75.110",
|
|
64
|
+
"@stacksjs/logging": "0.75.110",
|
|
65
|
+
"@stacksjs/path": "0.75.110",
|
|
66
|
+
"@stacksjs/strings": "0.75.110",
|
|
67
67
|
"@stacksjs/ts-cloud": "^0.16.47",
|
|
68
68
|
"@stacksjs/ts-xml": "^0.1.2"
|
|
69
69
|
},
|
|
70
70
|
"devDependencies": {
|
|
71
|
-
"@stacksjs/cli": "0.75.
|
|
72
|
-
"@stacksjs/dns": "0.75.
|
|
73
|
-
"@stacksjs/env": "0.75.
|
|
74
|
-
"@stacksjs/router": "0.75.
|
|
75
|
-
"@stacksjs/storage": "0.75.
|
|
76
|
-
"@stacksjs/utils": "0.75.
|
|
77
|
-
"@stacksjs/validation": "0.75.
|
|
71
|
+
"@stacksjs/cli": "0.75.110",
|
|
72
|
+
"@stacksjs/dns": "0.75.110",
|
|
73
|
+
"@stacksjs/env": "0.75.110",
|
|
74
|
+
"@stacksjs/router": "0.75.110",
|
|
75
|
+
"@stacksjs/storage": "0.75.110",
|
|
76
|
+
"@stacksjs/utils": "0.75.110",
|
|
77
|
+
"@stacksjs/validation": "0.75.110",
|
|
78
78
|
"better-dx": "^0.2.24"
|
|
79
79
|
}
|
|
80
80
|
}
|